Erste Produktivversion
This commit is contained in:
347
web/html/js/views/admin.js
Normal file
347
web/html/js/views/admin.js
Normal file
@@ -0,0 +1,347 @@
|
||||
// Benutzerverwaltung für Administratoren.
|
||||
"use strict";
|
||||
|
||||
import { del, get, post, put } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
import { state } from "../store.js";
|
||||
|
||||
let openId = null;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Wie lange ist das her, grob. Für "seit wann nicht mehr gesehen" ist
|
||||
* der Monat die passende Auflösung - der Tag wäre Scheingenauigkeit. */
|
||||
function ago(iso) {
|
||||
if (!iso) return "nie";
|
||||
const days = Math.floor((Date.now() - new Date(iso)) / 86400000);
|
||||
if (days < 1) return "heute";
|
||||
if (days === 1) return "gestern";
|
||||
if (days < 31) return `vor ${days} Tagen`;
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 24) return `vor ${months} Monaten`;
|
||||
return `vor ${Math.round(months / 12)} Jahren`;
|
||||
}
|
||||
|
||||
export async function adminView(root, { back }) {
|
||||
let users = [];
|
||||
let stats = null;
|
||||
let config = null;
|
||||
let banner = null;
|
||||
|
||||
async function reload() {
|
||||
[users, stats, config] = await Promise.all([
|
||||
get("/api/admin/users"),
|
||||
get("/api/admin/stats"),
|
||||
get("/api/admin/settings"),
|
||||
]);
|
||||
}
|
||||
|
||||
function say(message, kind = "notice") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 10000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
const result = await fn();
|
||||
await reload();
|
||||
render();
|
||||
if (result && result.detail) say(result.detail);
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Kennzahlen
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function statsCard() {
|
||||
const tile = (label, value, warn = false) =>
|
||||
el("div", { className: `stat${warn && value ? " warn" : ""}` },
|
||||
el("span.value", {}, String(value)),
|
||||
el("span.label", {}, label));
|
||||
|
||||
return el("section.card", {},
|
||||
el("h1", {}, "Benutzer"),
|
||||
el("div.stats", {},
|
||||
tile("gesamt", stats.total),
|
||||
tile("aktiv", stats.active),
|
||||
tile("deaktiviert", stats.inactive),
|
||||
tile("nicht eingerichtet", stats.unverified),
|
||||
tile("Administratoren", stats.admins)),
|
||||
stats.due_deactivation || stats.due_deletion
|
||||
? el("p.lead.warn", {},
|
||||
"Beim nächsten nächtlichen Durchlauf: ",
|
||||
stats.due_deactivation
|
||||
? `${stats.due_deactivation} Konto/Konten werden deaktiviert`
|
||||
: "",
|
||||
stats.due_deactivation && stats.due_deletion ? ", " : "",
|
||||
stats.due_deletion
|
||||
? `${stats.due_deletion} werden gelöscht`
|
||||
: "",
|
||||
".")
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Neues Konto
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function createCard() {
|
||||
const email = el("input", { type: "email", placeholder: "person@example.de" });
|
||||
const name = el("input", { type: "text", maxLength: 80, placeholder: "freiwillig" });
|
||||
const asAdmin = el("input", { type: "checkbox" });
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Konto anlegen"),
|
||||
el("p.lead", {},
|
||||
"Die Person bekommt eine Willkommensnachricht mit einem Link, über " +
|
||||
"den sie ihr Passwort selbst festlegt. Ein vom Administrator " +
|
||||
"vergebenes Passwort wäre ihm bekannt und ginge im Klartext per Mail."),
|
||||
el("label", {}, "E-Mail-Adresse"), email,
|
||||
el("label", {}, "Anzeigename"), name,
|
||||
el("label.checkline", {}, asAdmin,
|
||||
el("span", {}, "Administratorrechte erteilen")),
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
const value = email.value.trim();
|
||||
if (!value) return;
|
||||
guarded(async () => {
|
||||
await post("/api/admin/users", {
|
||||
email: value,
|
||||
display_name: name.value.trim() || null,
|
||||
is_admin: asAdmin.checked,
|
||||
});
|
||||
email.value = "";
|
||||
name.value = "";
|
||||
asAdmin.checked = false;
|
||||
say(`Konto angelegt, Willkommensnachricht an ${value} versendet.`);
|
||||
});
|
||||
},
|
||||
}, "Anlegen und einladen")
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Ein Konto
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function userActions(user) {
|
||||
const self = user.id === state.user.id;
|
||||
|
||||
const newEmail = el("input", { type: "email", placeholder: "neue Adresse" });
|
||||
|
||||
const blocks = [
|
||||
el("label", {}, "E-Mail-Adresse ändern"),
|
||||
el("p.sub", {},
|
||||
user.is_admin
|
||||
? "Administratorkonto: Die Änderung wird erst wirksam, wenn BEIDE " +
|
||||
"Adressen bestätigt haben – die neue und die bisherige."
|
||||
: "Die neue Adresse muss bestätigen; die bisherige bekommt einen " +
|
||||
"Hinweis, damit eine untergeschobene Änderung auffällt."),
|
||||
el("div.row", {}, newEmail,
|
||||
el("button.secondary", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
const value = newEmail.value.trim();
|
||||
if (!value) return;
|
||||
guarded(async () => {
|
||||
const result = await post(`/api/admin/users/${user.id}/email`,
|
||||
{ new_email: value });
|
||||
newEmail.value = "";
|
||||
return result;
|
||||
});
|
||||
},
|
||||
}, "Ändern")),
|
||||
];
|
||||
|
||||
if (user.pending_email) {
|
||||
blocks.push(el("p.lead.warn", {},
|
||||
`Adresswechsel zu ${user.pending_email} läuft. `,
|
||||
el("button.linklike", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => del(`/api/admin/users/${user.id}/email`)),
|
||||
}, "Zurückziehen")));
|
||||
}
|
||||
|
||||
if (!user.verified) {
|
||||
blocks.push(el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/welcome`)),
|
||||
}, "Willkommensnachricht erneut senden")));
|
||||
}
|
||||
|
||||
// Administratorkonten sind vor Deaktivierung und Löschung geschützt.
|
||||
if (user.is_admin) {
|
||||
blocks.push(el("p.sub", {},
|
||||
"Administratorkonten lassen sich nicht deaktivieren oder löschen. " +
|
||||
"Sonst könnte sich die Verwaltung selbst aussperren."));
|
||||
return el("div.item-menu", {}, blocks);
|
||||
}
|
||||
|
||||
if (self) {
|
||||
blocks.push(el("p.sub", {}, "Das eigene Konto lässt sich hier nicht ändern."));
|
||||
return el("div.item-menu", {}, blocks);
|
||||
}
|
||||
|
||||
blocks.push(el("div.menu-actions", {},
|
||||
user.is_active
|
||||
? el("button", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Konto ${user.email} deaktivieren?\n\n` +
|
||||
"Die Person kann sich nicht mehr anmelden. Listen und " +
|
||||
"Mitgliedschaften bleiben erhalten."
|
||||
)) return;
|
||||
guarded(() => post(`/api/admin/users/${user.id}/deactivate`));
|
||||
},
|
||||
}, "Deaktivieren")
|
||||
: el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/activate`)),
|
||||
}, "Reaktivieren")));
|
||||
|
||||
blocks.push(el("hr.thin", {}));
|
||||
blocks.push(el("label", {}, "Konto löschen"));
|
||||
blocks.push(el("p.sub", {},
|
||||
user.owned_lists
|
||||
? `Diesem Konto gehören ${user.owned_lists} Liste(n). Geteilte Listen ` +
|
||||
"gehen an das dienstälteste andere Mitglied über, Listen ohne " +
|
||||
"weitere Mitglieder werden gelöscht."
|
||||
: "Diesem Konto gehören keine Listen."));
|
||||
blocks.push(el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
const typed = prompt(
|
||||
`Konto ${user.email} endgültig löschen?\n\n` +
|
||||
"Zur Bestätigung die E-Mail-Adresse eingeben:");
|
||||
if (!typed) return;
|
||||
guarded(() => post(`/api/admin/users/${user.id}/delete`, {
|
||||
lists: "handover",
|
||||
confirm_email: typed.trim(),
|
||||
}));
|
||||
},
|
||||
}, "Endgültig löschen"));
|
||||
|
||||
return el("div.item-menu", {}, blocks);
|
||||
}
|
||||
|
||||
function userRow(user) {
|
||||
const facts = [
|
||||
user.is_admin ? "Administrator" : null,
|
||||
!user.verified ? "noch nicht eingerichtet" : null,
|
||||
!user.is_active ? `deaktiviert seit ${formatDate(user.deactivated_at)}` : null,
|
||||
user.is_active && user.verified ? `zuletzt ${ago(user.last_seen_at)}` : null,
|
||||
user.owned_lists ? `${user.owned_lists} eigene Liste(n)` : null,
|
||||
user.memberships ? `${user.memberships} Mitgliedschaft(en)` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return el("li", {
|
||||
className: `admin-user${user.is_active ? "" : " inactive"}`,
|
||||
},
|
||||
el("button.user-open", {
|
||||
type: "button",
|
||||
onclick: () => { openId = openId === user.id ? null : user.id; render(); },
|
||||
},
|
||||
el("span.name", {}, user.display_name || user.email),
|
||||
el("span.sub", {},
|
||||
user.display_name ? `${user.email} · ` : "",
|
||||
facts.join(" · "))),
|
||||
openId === user.id ? userActions(user) : null
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Automatik
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function automationCard() {
|
||||
const deactivate = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_deactivate_months),
|
||||
});
|
||||
const remove = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_delete_months),
|
||||
});
|
||||
const selfReg = el("input", {
|
||||
type: "checkbox",
|
||||
checked: config.allow_self_registration,
|
||||
disabled: config.locked_by_env,
|
||||
});
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Automatische Bereinigung"),
|
||||
el("p.lead", {},
|
||||
"Läuft einmal täglich. ",
|
||||
el("strong", {}, "0 bedeutet abgeschaltet"),
|
||||
", nicht „sofort“. Administratorkonten sind ausgenommen."),
|
||||
|
||||
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"),
|
||||
deactivate,
|
||||
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
|
||||
remove,
|
||||
|
||||
el("label.checkline", {}, selfReg,
|
||||
el("span", {}, "Selbstregistrierung erlauben")),
|
||||
config.locked_by_env
|
||||
? el("p.sub", {},
|
||||
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
|
||||
"und hier nicht änderbar.")
|
||||
: null,
|
||||
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await put("/api/admin/settings", {
|
||||
auto_deactivate_months: Number(deactivate.value) || 0,
|
||||
auto_delete_months: Number(remove.value) || 0,
|
||||
...(config.locked_by_env
|
||||
? {}
|
||||
: { allow_self_registration: selfReg.checked }),
|
||||
});
|
||||
say("Einstellungen gespeichert.");
|
||||
}),
|
||||
}, "Speichern"),
|
||||
|
||||
el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post("/api/admin/cleanup")),
|
||||
}, "Jetzt aufräumen"))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück")),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
statsCard(),
|
||||
el("section.card", {},
|
||||
el("h2", {}, "Konten"),
|
||||
el("ul.admin-users", {}, users.map(userRow))),
|
||||
createCard(),
|
||||
automationCard()
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
296
web/html/js/views/articles.js
Normal file
296
web/html/js/views/articles.js
Normal file
@@ -0,0 +1,296 @@
|
||||
// Artikelstamm einer Liste: Namen, Strichcodes, Vorgaben für Markt und
|
||||
// Warengruppe, Verfügbarkeit und frei definierbare Eigenschaften.
|
||||
"use strict";
|
||||
|
||||
import { del, get, patch, post } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
import { cameraAvailable, scanBarcode } from "./scanner.js";
|
||||
|
||||
let editingId = null;
|
||||
let query = "";
|
||||
|
||||
export async function articlesView(root, { listId, back }) {
|
||||
let articles = [];
|
||||
let banner = null;
|
||||
|
||||
async function reload() {
|
||||
const [list, markets, categories] = await Promise.all([
|
||||
get(`/api/lists/${listId}/articles${query ? `?q=${encodeURIComponent(query)}` : ""}`),
|
||||
get(`/api/lists/${listId}/markets`),
|
||||
get(`/api/lists/${listId}/categories`),
|
||||
]);
|
||||
articles = list;
|
||||
set({ markets, categories });
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Bearbeitungsformular
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function editor(article) {
|
||||
const name = el("input", { type: "text", value: article.name, maxLength: 200 });
|
||||
const barcode = el("input", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
value: article.barcode || "",
|
||||
maxLength: 64,
|
||||
placeholder: "keiner hinterlegt",
|
||||
});
|
||||
const note = el("input", {
|
||||
type: "text", value: article.note || "", maxLength: 500,
|
||||
placeholder: "z. B. Lieblingsmarke",
|
||||
});
|
||||
|
||||
const marketSelect = el("select", {},
|
||||
el("option", { value: "" }, "— keine Vorgabe —"),
|
||||
state.markets.map((m) => el("option", {
|
||||
value: m.id, selected: m.id === article.default_market_id,
|
||||
}, m.name)));
|
||||
|
||||
const categorySelect = el("select", {},
|
||||
el("option", { value: "" }, "— keine Vorgabe —"),
|
||||
state.categories.map((c) => el("option", {
|
||||
value: c.id, selected: c.id === article.default_category_id,
|
||||
}, c.name)));
|
||||
|
||||
// Verfügbarkeit: mehrere Märkte ankreuzbar
|
||||
const availability = state.markets.map((m) => {
|
||||
const box = el("input", {
|
||||
type: "checkbox",
|
||||
checked: article.available_market_ids.includes(m.id),
|
||||
dataset: { marketId: m.id },
|
||||
});
|
||||
return el("label.checkline", {}, box, el("span", {}, m.name));
|
||||
});
|
||||
|
||||
// Freie Eigenschaften
|
||||
const attrRows = el("div.attr-rows", {});
|
||||
|
||||
function addAttrRow(attr = { name: "", value: "" }) {
|
||||
const key = el("input.attr-key", {
|
||||
type: "text", value: attr.name, maxLength: 80, placeholder: "Eigenschaft",
|
||||
});
|
||||
const value = el("input.attr-value", {
|
||||
type: "text", value: attr.value, maxLength: 300, placeholder: "Wert",
|
||||
});
|
||||
const row = el("div.attr-row", {}, key, value,
|
||||
el("button.danger.remove", {
|
||||
type: "button", title: "Zeile entfernen",
|
||||
onclick: () => row.remove(),
|
||||
}, "×"));
|
||||
attrRows.append(row);
|
||||
}
|
||||
|
||||
for (const attr of article.attributes) addAttrRow(attr);
|
||||
if (!article.attributes.length) addAttrRow();
|
||||
|
||||
async function scan() {
|
||||
const code = await scanBarcode();
|
||||
if (code) barcode.value = code;
|
||||
}
|
||||
|
||||
/** Angaben zum eingetragenen Code aus der Produktdatenbank holen.
|
||||
* Überschreibt nur leere Felder - Gepflegtes bleibt stehen. */
|
||||
async function fetchProductData() {
|
||||
const code = barcode.value.replace(/\s/g, "");
|
||||
if (!code) {
|
||||
say("Erst einen Strichcode eintragen oder scannen.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hit = await get(
|
||||
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
|
||||
if (!hit.found || hit.source !== "openfoodfacts") {
|
||||
say("Zu diesem Code liegen keine Angaben vor.", "notice");
|
||||
return;
|
||||
}
|
||||
if (hit.name && !name.value.trim()) name.value = hit.name;
|
||||
if (hit.package && !note.value.trim()) note.value = hit.package;
|
||||
|
||||
const rows = [...attrRows.querySelectorAll(".attr-row")];
|
||||
const empty = rows.find(
|
||||
(r) => !r.querySelector(".attr-key").value.trim());
|
||||
if (hit.brand && empty) {
|
||||
empty.querySelector(".attr-key").value = "Marke";
|
||||
empty.querySelector(".attr-value").value = hit.brand;
|
||||
}
|
||||
say(`Angaben zu „${hit.name}“ übernommen. Noch nicht gespeichert.`,
|
||||
"notice");
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const attributes = [...attrRows.querySelectorAll(".attr-row")]
|
||||
.map((row) => ({
|
||||
name: row.querySelector(".attr-key").value.trim(),
|
||||
value: row.querySelector(".attr-value").value.trim(),
|
||||
}))
|
||||
.filter((a) => a.name);
|
||||
|
||||
const available = [...availability]
|
||||
.map((label) => label.querySelector("input"))
|
||||
.filter((box) => box.checked)
|
||||
.map((box) => box.dataset.marketId);
|
||||
|
||||
editingId = null;
|
||||
await guarded(() => patch(`/api/lists/${listId}/articles/${article.id}`, {
|
||||
name: name.value.trim() || article.name,
|
||||
barcode: barcode.value.replace(/\s/g, "") || null,
|
||||
note: note.value.trim() || null,
|
||||
default_market_id: marketSelect.value || null,
|
||||
default_category_id: categorySelect.value || null,
|
||||
attributes,
|
||||
available_market_ids: available,
|
||||
}));
|
||||
}
|
||||
|
||||
return el("div.article-editor", {},
|
||||
el("label", {}, "Name"), name,
|
||||
|
||||
el("label", {}, "Strichcode"),
|
||||
el("div.row", {}, barcode,
|
||||
cameraAvailable()
|
||||
? el("button.secondary", { type: "button", onclick: scan }, "Scannen")
|
||||
: null,
|
||||
el("button.secondary", {
|
||||
type: "button",
|
||||
title: "Bezeichnung und Packungsgröße aus der Produktdatenbank holen",
|
||||
onclick: fetchProductData,
|
||||
}, "Abrufen")),
|
||||
|
||||
el("label", {}, "Notiz"), note,
|
||||
el("label", {}, "Vorgabe Markt"), marketSelect,
|
||||
el("label", {}, "Vorgabe Warengruppe"), categorySelect,
|
||||
|
||||
state.markets.length
|
||||
? el("div.availability", {},
|
||||
el("label", {}, "Erhältlich bei"), availability)
|
||||
: null,
|
||||
|
||||
el("label", {}, "Eigenschaften",
|
||||
el("span.hint", {}, " (Verpackungseinheit, Farbe, Größe …)")),
|
||||
attrRows,
|
||||
el("button.linklike.add-attr", {
|
||||
type: "button", onclick: () => addAttrRow(),
|
||||
}, "+ weitere Eigenschaft"),
|
||||
|
||||
el("div.menu-actions", {},
|
||||
el("button.primary", { type: "button", onclick: save }, "Speichern"),
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => { editingId = null; render(); },
|
||||
}, "Abbrechen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Artikel „${article.name}“ löschen?\n\n` +
|
||||
"Offene Einträge dieses Artikels verschwinden mit."
|
||||
)) return;
|
||||
editingId = null;
|
||||
guarded(() => del(`/api/lists/${listId}/articles/${article.id}`));
|
||||
},
|
||||
}, "Löschen"))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Zeile
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function row(article) {
|
||||
if (editingId === article.id) {
|
||||
return el("li.article-row.editing", {},
|
||||
el("span.name", {}, article.name), editor(article));
|
||||
}
|
||||
|
||||
const marketName = state.markets.find((m) => m.id === article.default_market_id)?.name;
|
||||
const categoryName = state.categories.find(
|
||||
(c) => c.id === article.default_category_id)?.name;
|
||||
|
||||
const facts = [
|
||||
article.barcode ? `Code ${article.barcode}` : null,
|
||||
marketName,
|
||||
categoryName,
|
||||
article.attributes.length
|
||||
? article.attributes.map((a) => `${a.name}: ${a.value}`).join(", ")
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return el("li.article-row", {},
|
||||
el("button.article-open", {
|
||||
type: "button",
|
||||
onclick: () => { editingId = article.id; render(); },
|
||||
},
|
||||
el("span.name", {}, article.name),
|
||||
facts.length ? el("span.sub", {}, facts.join(" · ")) : null)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
const search = el("input", {
|
||||
type: "search",
|
||||
value: query,
|
||||
placeholder: "Artikel suchen",
|
||||
oninput: (ev) => {
|
||||
query = ev.target.value;
|
||||
clearTimeout(render._timer);
|
||||
render._timer = setTimeout(() => guarded(async () => {}), 250);
|
||||
},
|
||||
});
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back },
|
||||
"‹ Zurück zur Liste")),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Artikel"),
|
||||
el("p.lead", {},
|
||||
"Vorgaben für Markt und Warengruppe gelten für neue Einträge. " +
|
||||
"Ein hinterlegter Strichcode lässt den Artikel beim Scannen sofort " +
|
||||
"finden."),
|
||||
search,
|
||||
articles.length
|
||||
? el("ul.articles", {}, articles.map(row))
|
||||
: el("p.empty", {},
|
||||
query ? "Kein Artikel gefunden." : "Noch kein Artikel angelegt.")),
|
||||
|
||||
el("p.footnote", {},
|
||||
"Artikel entstehen automatisch, sobald du sie auf die Liste setzt.")
|
||||
);
|
||||
|
||||
if (query) {
|
||||
const field = root.querySelector('input[type="search"]');
|
||||
field.focus();
|
||||
field.setSelectionRange(field.value.length, field.value.length);
|
||||
}
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
233
web/html/js/views/auth.js
Normal file
233
web/html/js/views/auth.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// Anmeldung, Registrierung, Passwort zurücksetzen und Startpasswortwechsel.
|
||||
"use strict";
|
||||
|
||||
import { ApiError, get, post } from "../api.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
/** Baut ein Formular ohne <form>-Element: dessen Standardverhalten
|
||||
* (Seite neu laden) stört hier nur. Enter löst trotzdem aus. */
|
||||
function form({ title, lead, leadWarn, fields, submitLabel, onSubmit, links = [] }) {
|
||||
const inputs = {};
|
||||
const error = el("p.error", { hidden: true });
|
||||
const notice = el("p.notice", { hidden: true });
|
||||
const button = el("button.primary", { type: "button" }, submitLabel);
|
||||
|
||||
const showError = (message) => {
|
||||
error.textContent = message || "";
|
||||
error.hidden = !message;
|
||||
};
|
||||
const showNotice = (message) => {
|
||||
notice.textContent = message || "";
|
||||
notice.hidden = !message;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
showError("");
|
||||
showNotice("");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(inputs).map(([k, i]) => [k, i.value])
|
||||
);
|
||||
await onSubmit(values, { showNotice, showError });
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener("click", run);
|
||||
|
||||
const rows = fields.map((f) => {
|
||||
const input = el("input", {
|
||||
id: `f-${f.name}`,
|
||||
type: f.type || "text",
|
||||
autocomplete: f.autocomplete || "off",
|
||||
maxLength: f.maxLength || 256,
|
||||
onkeydown: (ev) => {
|
||||
if (ev.key === "Enter") run();
|
||||
},
|
||||
});
|
||||
inputs[f.name] = input;
|
||||
return [
|
||||
el("label", { htmlFor: `f-${f.name}` },
|
||||
f.label,
|
||||
f.hint ? el("span.hint", {}, ` ${f.hint}`) : null),
|
||||
input,
|
||||
];
|
||||
});
|
||||
|
||||
return el("section.card", {},
|
||||
el("h1", {}, title),
|
||||
lead ? el(leadWarn ? "p.lead.warn" : "p.lead", {}, lead) : null,
|
||||
rows,
|
||||
error,
|
||||
notice,
|
||||
button,
|
||||
links.length
|
||||
? el("p.switch", {}, links.flatMap((l, i) => [
|
||||
i > 0 ? " · " : null,
|
||||
el("a", { href: "#", onclick: (ev) => { ev.preventDefault(); l.action(); } },
|
||||
l.label),
|
||||
]))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
export function loginView(root, { goto, onSignedIn }) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const verified = params.get("verified");
|
||||
const emailChange = params.get("adresswechsel");
|
||||
|
||||
const node = form({
|
||||
title: state.appName,
|
||||
lead: "Bitte anmelden.",
|
||||
fields: [
|
||||
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||
{ name: "password", label: "Passwort", type: "password", autocomplete: "current-password" },
|
||||
],
|
||||
submitLabel: "Anmelden",
|
||||
onSubmit: async ({ email, password }) => {
|
||||
const user = await post("/api/auth/login", { email: email.trim(), password });
|
||||
onSignedIn(user);
|
||||
},
|
||||
links: [
|
||||
{ label: "Konto anlegen", action: () => goto("register") },
|
||||
{ label: "Passwort vergessen", action: () => goto("forgot") },
|
||||
],
|
||||
});
|
||||
|
||||
mount(root, node);
|
||||
|
||||
if (verified === "ok") {
|
||||
const notice = node.querySelector(".notice");
|
||||
notice.textContent = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden.";
|
||||
notice.hidden = false;
|
||||
history.replaceState(null, "", "/");
|
||||
} else if (verified === "invalid") {
|
||||
const error = node.querySelector(".error");
|
||||
error.textContent = "Der Bestätigungslink ist ungültig oder abgelaufen.";
|
||||
error.hidden = false;
|
||||
history.replaceState(null, "", "/");
|
||||
}
|
||||
|
||||
if (emailChange) {
|
||||
const messages = {
|
||||
fertig: ["notice",
|
||||
"E-Mail-Adresse geändert. Bitte melde dich mit der neuen Adresse an."],
|
||||
teilweise: ["notice",
|
||||
"Bestätigung angekommen. Bei Administratorkonten muss auch die " +
|
||||
"zweite Adresse zustimmen – erst danach wird die Änderung wirksam."],
|
||||
abgelaufen: ["error", "Der Bestätigungslink ist abgelaufen."],
|
||||
erledigt: ["notice", "Dieser Adresswechsel ist bereits abgeschlossen."],
|
||||
unbekannt: ["error", "Dieser Bestätigungslink ist unbekannt."],
|
||||
};
|
||||
const entry = messages[emailChange];
|
||||
if (entry) {
|
||||
const target = node.querySelector(entry[0] === "error" ? ".error" : ".notice");
|
||||
target.textContent = entry[1];
|
||||
target.hidden = false;
|
||||
history.replaceState(null, "", "/");
|
||||
}
|
||||
}
|
||||
|
||||
node.querySelector("input").focus();
|
||||
}
|
||||
|
||||
export function registerView(root, { goto }) {
|
||||
mount(root, form({
|
||||
title: "Konto anlegen",
|
||||
fields: [
|
||||
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||
{ name: "display_name", label: "Anzeigename", hint: "(freiwillig)", maxLength: 80 },
|
||||
{ name: "password", label: "Passwort", hint: "(mindestens 12 Zeichen)",
|
||||
type: "password", autocomplete: "new-password" },
|
||||
],
|
||||
submitLabel: "Registrieren",
|
||||
onSubmit: async (v, { showNotice }) => {
|
||||
const result = await post("/api/auth/register", {
|
||||
email: v.email.trim(),
|
||||
password: v.password,
|
||||
display_name: v.display_name.trim() || null,
|
||||
});
|
||||
showNotice(result.detail);
|
||||
},
|
||||
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
|
||||
}));
|
||||
}
|
||||
|
||||
export function forgotView(root, { goto }) {
|
||||
mount(root, form({
|
||||
title: "Passwort zurücksetzen",
|
||||
lead: "Wir schicken dir einen Link, sofern die Adresse bei uns registriert ist.",
|
||||
fields: [
|
||||
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||
],
|
||||
submitLabel: "Link anfordern",
|
||||
onSubmit: async ({ email }, { showNotice }) => {
|
||||
const result = await post("/api/auth/password/reset-request", { email: email.trim() });
|
||||
showNotice(result.detail);
|
||||
},
|
||||
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
|
||||
}));
|
||||
}
|
||||
|
||||
export function resetView(root, { goto }) {
|
||||
const token = new URLSearchParams(location.search).get("token") || "";
|
||||
mount(root, form({
|
||||
title: "Neues Passwort setzen",
|
||||
fields: [
|
||||
{ name: "password", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
|
||||
type: "password", autocomplete: "new-password" },
|
||||
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
|
||||
],
|
||||
submitLabel: "Passwort setzen",
|
||||
onSubmit: async ({ password, repeat }, { showNotice }) => {
|
||||
if (password !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||
const result = await post("/api/auth/password/reset", { token, password });
|
||||
showNotice(result.detail);
|
||||
history.replaceState(null, "", "/");
|
||||
setTimeout(() => goto("login"), 1500);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function changePasswordView(root, { onDone, onLogout }) {
|
||||
mount(root, form({
|
||||
title: "Passwort ändern",
|
||||
lead: "Dieses Konto wurde mit einem Startpasswort aus der Konfiguration angelegt. " +
|
||||
"Bevor du weiterarbeiten kannst, musst du ein eigenes Passwort setzen.",
|
||||
leadWarn: true,
|
||||
fields: [
|
||||
{ name: "current", label: "Bisheriges Passwort", type: "password",
|
||||
autocomplete: "current-password" },
|
||||
{ name: "next", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
|
||||
type: "password", autocomplete: "new-password" },
|
||||
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
|
||||
],
|
||||
submitLabel: "Passwort ändern",
|
||||
onSubmit: async ({ current, next, repeat }) => {
|
||||
if (next !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||
await post("/api/auth/password/change", {
|
||||
current_password: current,
|
||||
new_password: next,
|
||||
});
|
||||
const user = await get("/api/auth/me");
|
||||
set({ user });
|
||||
onDone(user);
|
||||
},
|
||||
links: [{ label: "Abmelden", action: onLogout }],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await post("/api/auth/logout");
|
||||
} catch (err) {
|
||||
// Auch wenn der Server nicht erreichbar ist: lokal abmelden.
|
||||
if (!(err instanceof ApiError)) { /* offline - egal */ }
|
||||
}
|
||||
set({ user: null, lists: [], view: null });
|
||||
}
|
||||
600
web/html/js/views/list-detail.js
Normal file
600
web/html/js/views/list-detail.js
Normal file
@@ -0,0 +1,600 @@
|
||||
// Eine geöffnete Liste: gruppiert nach Markt und Warengruppe, Artikel
|
||||
// alphabetisch. Arbeitet gegen den lokalen Zwischenspeicher, nicht gegen
|
||||
// den Server - dadurch funktioniert die Ansicht auch ohne Verbindung.
|
||||
//
|
||||
// Jede Änderung wandert zuerst in die Outbox und wird sofort angezeigt.
|
||||
// Der Versand läuft danach; scheitert er, bleibt die Operation liegen und
|
||||
// wird beim nächsten Anlauf erneut versucht.
|
||||
"use strict";
|
||||
|
||||
// get/ApiError nur für die Strichcode-Auflösung: Sie braucht eine
|
||||
// Verbindung, weil die Zuordnung Code -> Artikel serverseitig steht.
|
||||
import { ApiError, get } from "../api.js";
|
||||
import * as db from "../db.js";
|
||||
import {
|
||||
clear, el, euro, formatPack, formatQuantity, mount,
|
||||
parseCents, parseCount, parseQuantity,
|
||||
} from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
import { composeView, flush, pull, syncNow, watch } from "../sync.js";
|
||||
import { confirmScan } from "./scan-result.js";
|
||||
import { cameraAvailable, scanBarcode } from "./scanner.js";
|
||||
|
||||
let openMenuId = null;
|
||||
|
||||
export async function listDetailView(
|
||||
root, { listId, back, manage, share, articles, prices }
|
||||
) {
|
||||
let view = null;
|
||||
let meta = null;
|
||||
let markets = [];
|
||||
let categories = [];
|
||||
let banner = null;
|
||||
/** article_id -> { best_market_ids, best_cents, spread_cents, prices } */
|
||||
let priceHints = new Map();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Laden
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Preishinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet
|
||||
* die Liste ohne sie weiter. Deshalb hier kein throw, sondern der
|
||||
* Rückgriff auf den zuletzt bekannten Stand. */
|
||||
async function loadHints() {
|
||||
if (!state.online) {
|
||||
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
|
||||
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rows = await get(`/api/lists/${listId}/price-hints`);
|
||||
priceHints = new Map(rows.map((r) => [r.article_id, r]));
|
||||
await db.cacheSet(`hints:${listId}`, rows);
|
||||
} catch {
|
||||
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
|
||||
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocal() {
|
||||
view = await composeView(listId);
|
||||
markets = (await db.cacheGet(`markets:${listId}`)) || [];
|
||||
categories = (await db.cacheGet(`categories:${listId}`)) || [];
|
||||
meta = await db.cacheGet(`meta:${listId}`);
|
||||
}
|
||||
|
||||
async function refresh({ fromServer = false } = {}) {
|
||||
if (fromServer) {
|
||||
try {
|
||||
await pull(listId);
|
||||
set({ syncState: "ok" });
|
||||
} catch {
|
||||
set({ syncState: navigator.onLine ? "error" : "offline" });
|
||||
}
|
||||
}
|
||||
await readLocal();
|
||||
render();
|
||||
}
|
||||
|
||||
function say(message) {
|
||||
banner = message;
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
/** Änderung anmelden: in die Outbox, sofort anzeigen, dann senden. */
|
||||
async function change(kind, payload) {
|
||||
await db.enqueue(listId, kind, payload);
|
||||
await readLocal();
|
||||
render();
|
||||
|
||||
const sent = await flush(listId);
|
||||
if (sent) {
|
||||
await pull(listId).catch(() => {});
|
||||
await readLocal();
|
||||
}
|
||||
if (state.syncProblem) {
|
||||
const problem = state.syncProblem;
|
||||
set({ syncProblem: null });
|
||||
say(problem);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function itemRow(item) {
|
||||
const bought = item.status === "bought";
|
||||
const deferred = item.status === "deferred";
|
||||
// Ein neu angelegter, noch nicht bestätigter Eintrag hat serverseitig
|
||||
// keine ID - Folgeänderungen daran ließen sich nicht zuordnen.
|
||||
const provisional = Boolean(item.pending && item.row_rev === 0);
|
||||
|
||||
const check = el("button.check", {
|
||||
type: "button",
|
||||
"aria-pressed": bought ? "true" : "false",
|
||||
title: bought ? "Als offen markieren" : "Als gekauft markieren",
|
||||
onclick: () => change("item.update", {
|
||||
item_id: item.id,
|
||||
status: bought ? "open" : "bought",
|
||||
}),
|
||||
disabled: provisional,
|
||||
}, bought ? "✓" : "");
|
||||
|
||||
const priceField = el("input.price", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
placeholder: "€",
|
||||
value: item.price_cents ? (item.price_cents / 100).toFixed(2).replace(".", ",") : "",
|
||||
title: provisional ? "Erst nach der Übertragung möglich" : "Preis erfassen",
|
||||
disabled: provisional,
|
||||
onchange: (ev) => {
|
||||
const cents = parseCents(ev.target.value);
|
||||
if (ev.target.value.trim() && cents === null) {
|
||||
ev.target.value = "";
|
||||
say("Preis konnte nicht gelesen werden.");
|
||||
return;
|
||||
}
|
||||
change("item.update", { item_id: item.id, price_cents: cents });
|
||||
},
|
||||
});
|
||||
|
||||
const menuButton = el("button.menu-toggle", {
|
||||
type: "button",
|
||||
title: "Weitere Aktionen",
|
||||
disabled: provisional,
|
||||
onclick: () => {
|
||||
openMenuId = openMenuId === item.id ? null : item.id;
|
||||
render();
|
||||
},
|
||||
}, "⋯");
|
||||
|
||||
const pack = formatPack(item.pack_size, item.pack_unit);
|
||||
|
||||
const shared = meta && meta.member_count > 1;
|
||||
const by = shared && item.created_by_name ? `von ${item.created_by_name}` : "";
|
||||
const sub = [pack, item.variant, item.note, by].filter(Boolean).join(" · ");
|
||||
|
||||
return el("li", {
|
||||
className: [
|
||||
"item",
|
||||
bought ? "bought" : "",
|
||||
deferred ? "deferred" : "",
|
||||
item.pending ? "pending" : "",
|
||||
].filter(Boolean).join(" "),
|
||||
},
|
||||
el("div.item-main", {},
|
||||
check,
|
||||
// Stückzahl direkt am Artikel: Beim Einkaufen ist "wie viele"
|
||||
// die wichtigste Angabe und darf nicht im Untertitel untergehen.
|
||||
el("span", {
|
||||
className: `item-count${item.count > 1 ? " many" : ""}`,
|
||||
title: item.count === 1 ? "1 Stück" : `${item.count} Stück`,
|
||||
}, `${item.count || 1}\u00d7`),
|
||||
el("div.item-text", {},
|
||||
el("span.name", {}, item.article_name),
|
||||
sub ? el("span.sub", {}, sub) : null),
|
||||
el("div.price-cell", {},
|
||||
priceField,
|
||||
// Bei mehreren Stück wird sichtbar, dass der Preis je Gebinde
|
||||
// gilt und was zusammen daraus wird.
|
||||
item.price_cents && item.count > 1
|
||||
? el("span.line-total", {}, `= ${euro(item.total_cents)}`)
|
||||
: null),
|
||||
menuButton),
|
||||
openMenuId === item.id && !provisional ? itemMenu(item) : null
|
||||
);
|
||||
}
|
||||
|
||||
function itemMenu(item) {
|
||||
const marketSelect = el("select", {
|
||||
onchange: (ev) => {
|
||||
const value = ev.target.value;
|
||||
openMenuId = null;
|
||||
change("item.update", value
|
||||
? { item_id: item.id, market_id: value }
|
||||
: { item_id: item.id, clear_market: true });
|
||||
},
|
||||
},
|
||||
el("option", { value: "" }, "— ohne Markt —"),
|
||||
markets.map((m) =>
|
||||
el("option", { value: m.id, selected: m.id === item.market_id }, m.name))
|
||||
);
|
||||
|
||||
const categorySelect = el("select", {
|
||||
onchange: (ev) => {
|
||||
const value = ev.target.value;
|
||||
openMenuId = null;
|
||||
change("item.update", value
|
||||
? { item_id: item.id, category_id: value }
|
||||
: { item_id: item.id, clear_category: true });
|
||||
},
|
||||
},
|
||||
el("option", { value: "" }, "— ohne Warengruppe —"),
|
||||
categories.map((c) =>
|
||||
el("option", { value: c.id, selected: c.id === item.category_id }, c.name))
|
||||
);
|
||||
|
||||
const deferred = item.status === "deferred";
|
||||
|
||||
const variantField = el("input", {
|
||||
type: "text",
|
||||
value: item.variant || "",
|
||||
maxLength: 200,
|
||||
placeholder: "z. B. bunt, ganz",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id,
|
||||
variant: ev.target.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const noteField = el("input", {
|
||||
type: "text",
|
||||
value: item.note || "",
|
||||
maxLength: 500,
|
||||
placeholder: "Bemerkung für den Einkauf",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id,
|
||||
note: ev.target.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const hint = priceHints.get(item.article_id);
|
||||
const hintBlock = hint ? (() => {
|
||||
const bestNames = hint.best_market_ids
|
||||
.map((id) => markets.find((m) => m.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const hereCents = item.market_id ? hint.prices[item.market_id] : null;
|
||||
if (!bestNames) return null;
|
||||
|
||||
const alreadyBest = item.market_id
|
||||
&& hint.best_market_ids.includes(item.market_id);
|
||||
|
||||
return el("p.price-tip", {},
|
||||
alreadyBest
|
||||
? `Hier am günstigsten: ${euro(hint.best_cents)}.`
|
||||
: `Zuletzt günstigster Markt: ${bestNames} für ${euro(hint.best_cents)}`,
|
||||
!alreadyBest && hereCents
|
||||
? ` – hier zuletzt ${euro(hereCents)}.`
|
||||
: !alreadyBest ? "." : "");
|
||||
})() : null;
|
||||
|
||||
const countEdit = el("input", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
value: String(item.count || 1),
|
||||
title: "Stückzahl",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id, count: parseCount(ev.target.value),
|
||||
}),
|
||||
});
|
||||
const packSizeEdit = el("input.qty", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
value: item.pack_size ? formatQuantity(item.pack_size) : "",
|
||||
placeholder: "Gebinde",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id, pack_size: parseQuantity(ev.target.value),
|
||||
}),
|
||||
});
|
||||
const packUnitEdit = el("input.unit", {
|
||||
type: "text",
|
||||
maxLength: 32,
|
||||
value: item.pack_unit || "",
|
||||
placeholder: "Einheit",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id, pack_unit: ev.target.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
return el("div.item-menu", {},
|
||||
hintBlock,
|
||||
el("label", {}, "Anzahl und Gebinde",
|
||||
el("span.hint", {}, " (Preis gilt je Gebinde)")),
|
||||
el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit),
|
||||
el("label", {}, "Eigenschaft"), variantField,
|
||||
el("label", {}, "Notiz"), noteField,
|
||||
el("label", {}, "Markt"), marketSelect,
|
||||
el("label", {}, "Warengruppe"), categorySelect,
|
||||
el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
openMenuId = null;
|
||||
change("item.update", {
|
||||
item_id: item.id,
|
||||
status: deferred ? "open" : "deferred",
|
||||
});
|
||||
},
|
||||
}, deferred ? "Wieder aufnehmen" : "Zurückstellen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
openMenuId = null;
|
||||
change("item.delete", { item_id: item.id });
|
||||
},
|
||||
}, "Löschen"))
|
||||
);
|
||||
}
|
||||
|
||||
function marketSection(market) {
|
||||
return el("section.market", {},
|
||||
el("h2", {},
|
||||
el("span", {}, market.market_name),
|
||||
el("span.market-meta", {},
|
||||
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
|
||||
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
|
||||
market.categories.map((cat) =>
|
||||
el("div.category", {},
|
||||
el("h3", {}, cat.category_name),
|
||||
el("ul.items", {}, cat.items.map(itemRow))))
|
||||
);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const nameField = el("input", {
|
||||
id: "add-name",
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
placeholder: "Artikel hinzufügen",
|
||||
autocomplete: "off",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
// Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die
|
||||
// Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben
|
||||
// 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
|
||||
const countField = el("input.count", {
|
||||
type: "text", inputMode: "numeric", placeholder: "Anz.", value: "",
|
||||
title: "Stückzahl – wie viele Packungen",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
const qtyField = el("input.qty", {
|
||||
type: "text", inputMode: "decimal", placeholder: "Gebinde",
|
||||
title: "Packungsgröße, z. B. 500",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
const unitField = el("input.unit", {
|
||||
type: "text", maxLength: 32, placeholder: "Einheit",
|
||||
title: "z. B. ml, g, Stk",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
|
||||
// Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss
|
||||
// "bunt, ganz" nur einmal getippt werden.
|
||||
const suggestions = new Set();
|
||||
for (const market of view?.markets || []) {
|
||||
for (const cat of market.categories) {
|
||||
for (const item of cat.items) {
|
||||
if (item.variant) suggestions.add(item.variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
const variantList = el("datalist", { id: "variant-suggestions" },
|
||||
[...suggestions].sort((a, b) => a.localeCompare(b, "de"))
|
||||
.map((v) => el("option", { value: v })));
|
||||
|
||||
const variantField = el("input.variant", {
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
placeholder: "Eigenschaft",
|
||||
title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“",
|
||||
autocomplete: "off",
|
||||
list: "variant-suggestions",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
// el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt
|
||||
// gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht.
|
||||
variantField.setAttribute("list", "variant-suggestions");
|
||||
|
||||
async function addItem() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) return;
|
||||
const payload = {
|
||||
article_name: name,
|
||||
count: parseCount(countField.value),
|
||||
pack_size: parseQuantity(qtyField.value),
|
||||
pack_unit: unitField.value.trim() || null,
|
||||
variant: variantField.value.trim() || null,
|
||||
};
|
||||
nameField.value = "";
|
||||
countField.value = "";
|
||||
qtyField.value = "";
|
||||
unitField.value = "";
|
||||
variantField.value = "";
|
||||
await change("item.create", payload);
|
||||
document.getElementById("add-name")?.focus();
|
||||
}
|
||||
|
||||
/** Strichcode scannen, auflösen, bestätigen, hinzufügen.
|
||||
*
|
||||
* Braucht Verbindung: Sowohl der eigene Artikelstamm als auch die
|
||||
* Produktdatenbank liegen auf dem Server. Offline bliebe eine
|
||||
* Ziffernfolge ohne Bedeutung - deshalb hier ehrlich abbrechen,
|
||||
* statt etwas Unbrauchbares in die Warteschlange zu legen. */
|
||||
async function scanAndAdd() {
|
||||
const code = await scanBarcode();
|
||||
if (!code) return;
|
||||
|
||||
if (!state.online) {
|
||||
say("Zum Scannen wird eine Verbindung gebraucht – die Zuordnung " +
|
||||
"des Codes steht auf dem Server.");
|
||||
return;
|
||||
}
|
||||
|
||||
let lookup;
|
||||
try {
|
||||
lookup = await get(
|
||||
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bereits im eigenen Bestand: ohne Rückfrage auf die Liste.
|
||||
if (lookup.source === "catalog") {
|
||||
await change("item.create", {
|
||||
article_id: lookup.article_id,
|
||||
barcode: code,
|
||||
count: parseCount(countField.value),
|
||||
pack_size: parseQuantity(qtyField.value),
|
||||
pack_unit: unitField.value.trim() || null,
|
||||
variant: variantField.value.trim() || null,
|
||||
});
|
||||
countField.value = "";
|
||||
qtyField.value = "";
|
||||
unitField.value = "";
|
||||
variantField.value = "";
|
||||
say(`„${lookup.name}“ hinzugefügt.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sonst bestätigen lassen - auch bei einem Treffer in der
|
||||
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
|
||||
// eigenen Artikelstamm wandern.
|
||||
const confirmed = await confirmScan(lookup);
|
||||
if (!confirmed) return;
|
||||
|
||||
await change("item.create", {
|
||||
article_name: confirmed.name,
|
||||
barcode: code,
|
||||
count: parseCount(confirmed.count),
|
||||
pack_size: parseQuantity(confirmed.packSize),
|
||||
pack_unit: confirmed.packUnit || null,
|
||||
variant: confirmed.variant || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Aufbau: Name über die volle Breite, darunter die Detailfelder,
|
||||
// darunter die Schaltflächen. Vorher standen Felder und
|
||||
// Schaltflächen in einer Zeile - auf schmalen Geräten lief der
|
||||
// letzte Knopf aus der Box.
|
||||
return el("div.add-row", {},
|
||||
nameField,
|
||||
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
|
||||
el("div.add-actions", {},
|
||||
cameraAvailable()
|
||||
? el("button.secondary", {
|
||||
type: "button",
|
||||
title: "Strichcode scannen",
|
||||
onclick: scanAndAdd,
|
||||
}, "Scannen")
|
||||
: null,
|
||||
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")),
|
||||
variantList
|
||||
);
|
||||
}
|
||||
|
||||
function statusLine() {
|
||||
const pendingCount = view?.pending_ops || 0;
|
||||
|
||||
if (!state.online) {
|
||||
return el("p.sync-hint.offline", {},
|
||||
"Offline – Änderungen werden gespeichert und später übertragen",
|
||||
pendingCount ? ` (${pendingCount} wartend).` : ".");
|
||||
}
|
||||
if (pendingCount) {
|
||||
return el("p.sync-hint.pending", {},
|
||||
pendingCount === 1
|
||||
? "1 Änderung wird übertragen …"
|
||||
: `${pendingCount} Änderungen werden übertragen …`);
|
||||
}
|
||||
if (state.syncState === "error") {
|
||||
return el("p.sync-hint.problem", {},
|
||||
"Der Server ist gerade nicht erreichbar. ",
|
||||
el("button.linklike", {
|
||||
type: "button",
|
||||
onclick: () => refresh({ fromServer: true }),
|
||||
}, "Erneut versuchen"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Gesamtansicht
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (!view) {
|
||||
mount(root, el("p.loading", {}, "Wird geladen …"));
|
||||
return;
|
||||
}
|
||||
|
||||
const boughtCount = view.markets.reduce(
|
||||
(sum, m) => sum + m.categories.reduce(
|
||||
(n, c) => n + c.items.filter((i) => i.status === "bought").length, 0), 0);
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Listen"),
|
||||
el("span.bar-actions", {},
|
||||
// Serverseitiger Ausdruck: eigene Seite, damit auch ohne
|
||||
// geöffnete App gedruckt werden kann.
|
||||
el("a.linklike", {
|
||||
href: `/api/lists/${listId}/print`,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
}, "Drucken"),
|
||||
el("button.linklike", { type: "button", onclick: () => prices(listId) },
|
||||
"Preise"),
|
||||
el("button.linklike", { type: "button", onclick: () => articles(listId) },
|
||||
"Artikel"),
|
||||
el("button.linklike", { type: "button", onclick: () => manage(listId) },
|
||||
"Märkte & Gruppen"),
|
||||
meta && meta.may_share_public
|
||||
? el("button.linklike", { type: "button", onclick: () => share(listId) },
|
||||
meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen")
|
||||
: null)),
|
||||
|
||||
statusLine(),
|
||||
banner ? el("p.error", {}, banner) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, view.list_name),
|
||||
addRow()),
|
||||
|
||||
view.markets.length
|
||||
? view.markets.map(marketSection)
|
||||
: el("p.empty", {}, "Die Liste ist leer."),
|
||||
|
||||
el("footer.summary", {},
|
||||
el("div.total", {},
|
||||
el("span", {}, "Gesamt"),
|
||||
el("strong", {}, euro(view.grand_total_cents))),
|
||||
boughtCount
|
||||
? el("button.secondary", {
|
||||
type: "button",
|
||||
onclick: () => change("items.clear_bought", {}),
|
||||
}, boughtCount === 1
|
||||
? "1 gekauften Eintrag entfernen"
|
||||
: `${boughtCount} gekaufte Einträge entfernen`)
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Start
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Erst den lokalen Stand zeigen - die App startet dadurch sofort,
|
||||
// auch ohne Verbindung.
|
||||
await readLocal();
|
||||
render();
|
||||
|
||||
// Dann im Hintergrund abgleichen.
|
||||
await syncNow(listId);
|
||||
await readLocal();
|
||||
await loadHints();
|
||||
render();
|
||||
|
||||
// Änderungen anderer Geräte.
|
||||
watch(listId, async () => {
|
||||
await readLocal();
|
||||
render();
|
||||
});
|
||||
|
||||
return () => { readLocal().then(render); };
|
||||
}
|
||||
232
web/html/js/views/lists.js
Normal file
232
web/html/js/views/lists.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// Übersicht aller Listen des angemeldeten Nutzers.
|
||||
"use strict";
|
||||
|
||||
import { del, get, patch, post } from "../api.js";
|
||||
import * as db from "../db.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
// Welche Zeile hat gerade ihr Menü offen
|
||||
let openMenuId = null;
|
||||
// Welche Zeile wird gerade umbenannt
|
||||
let renamingId = null;
|
||||
|
||||
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
|
||||
|
||||
export async function listsView(root, { openList, onLogout, settings }) {
|
||||
let banner = null;
|
||||
|
||||
async function reload() {
|
||||
set({ lists: await get("/api/lists") });
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Eine Zeile
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function row(entry) {
|
||||
const isOwner = entry.role === "owner";
|
||||
|
||||
if (renamingId === entry.id) {
|
||||
const field = el("input", {
|
||||
type: "text",
|
||||
value: entry.name,
|
||||
maxLength: 120,
|
||||
onkeydown: (ev) => {
|
||||
if (ev.key === "Enter") commit();
|
||||
if (ev.key === "Escape") { renamingId = null; render(); }
|
||||
},
|
||||
});
|
||||
|
||||
function commit() {
|
||||
const name = field.value.trim();
|
||||
if (!name) {
|
||||
say("Der Name darf nicht leer sein.");
|
||||
return;
|
||||
}
|
||||
renamingId = null;
|
||||
if (name === entry.name) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
guarded(() => patch(`/api/lists/${entry.id}`, { name }));
|
||||
}
|
||||
|
||||
const node = el("li.list-row.renaming", {},
|
||||
el("div.row", {}, field,
|
||||
el("button.primary", { type: "button", onclick: commit }, "Speichern"),
|
||||
el("button", {
|
||||
type: "button",
|
||||
className: "cancel",
|
||||
onclick: () => { renamingId = null; render(); },
|
||||
}, "Abbrechen"))
|
||||
);
|
||||
queueMicrotask(() => { field.focus(); field.select(); });
|
||||
return node;
|
||||
}
|
||||
|
||||
const people = entry.member_count === 1
|
||||
? "nur ich"
|
||||
: `${entry.member_count} Personen`;
|
||||
|
||||
return el("li.list-row", {},
|
||||
el("div.list-main", {},
|
||||
el("button.list-open", { type: "button", onclick: () => openList(entry.id) },
|
||||
el("span.name", {}, entry.name),
|
||||
el("span.meta", {}, `${people} · ${ROLE_LABEL[entry.role] || entry.role}`)),
|
||||
el("button.menu-toggle", {
|
||||
type: "button",
|
||||
title: "Weitere Aktionen",
|
||||
"aria-label": `Aktionen für ${entry.name}`,
|
||||
onclick: () => {
|
||||
openMenuId = openMenuId === entry.id ? null : entry.id;
|
||||
render();
|
||||
},
|
||||
}, "⋯")),
|
||||
|
||||
openMenuId === entry.id
|
||||
? el("div.list-menu", {},
|
||||
isOwner
|
||||
? el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
openMenuId = null;
|
||||
renamingId = entry.id;
|
||||
render();
|
||||
},
|
||||
}, "Umbenennen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => remove(entry),
|
||||
}, "Löschen"))
|
||||
: el("div.menu-actions", {},
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => leave(entry),
|
||||
}, "Liste verlassen")))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function remove(entry) {
|
||||
const extra = entry.member_count > 1
|
||||
? `\n\n${entry.member_count - 1} weitere Person(en) verlieren damit den Zugriff.`
|
||||
: "";
|
||||
if (!confirm(`Liste „${entry.name}“ löschen?${extra}`)) return;
|
||||
openMenuId = null;
|
||||
guarded(async () => {
|
||||
await del(`/api/lists/${entry.id}`);
|
||||
// Zwischenspeicher der gelöschten Liste mit aufräumen, sonst
|
||||
// bleibt ein verwaister Stand in IndexedDB liegen.
|
||||
await forgetLocally(entry.id);
|
||||
});
|
||||
}
|
||||
|
||||
function leave(entry) {
|
||||
if (!confirm(
|
||||
`Liste „${entry.name}“ verlassen?\n\n` +
|
||||
"Du siehst sie danach nicht mehr. Der Eigentümer kann dich erneut einladen."
|
||||
)) return;
|
||||
openMenuId = null;
|
||||
guarded(async () => {
|
||||
await post(`/api/lists/${entry.id}/leave`);
|
||||
await forgetLocally(entry.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function forgetLocally(listId) {
|
||||
await Promise.allSettled([
|
||||
db.cacheDelete(`view:${listId}`),
|
||||
db.cacheDelete(`markets:${listId}`),
|
||||
db.cacheDelete(`categories:${listId}`),
|
||||
db.cacheDelete(`meta:${listId}`),
|
||||
db.clearList(listId),
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Neue Liste
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function createRow() {
|
||||
const field = el("input", {
|
||||
id: "new-list",
|
||||
type: "text",
|
||||
maxLength: 120,
|
||||
placeholder: "z. B. Wocheneinkauf",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") create(); },
|
||||
});
|
||||
const button = el("button.primary", { type: "button", onclick: () => create() },
|
||||
"Anlegen");
|
||||
|
||||
async function create() {
|
||||
const name = field.value.trim();
|
||||
if (!name) {
|
||||
say("Bitte einen Namen eingeben.");
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await post("/api/lists", { name });
|
||||
field.value = "";
|
||||
await reload();
|
||||
// Ohne diesen Aufruf blieb die neue Liste unsichtbar, bis die
|
||||
// Seite neu geladen wurde: Der Zustandsspeicher war aktuell,
|
||||
// die Ansicht hatte sich aber nie dafür angemeldet.
|
||||
render();
|
||||
document.getElementById("new-list")?.focus();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return el("div.row", {}, field, button);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Gesamtansicht
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("span.who", {}, state.user.display_name || state.user.email),
|
||||
el("span.bar-actions", {},
|
||||
el("button.linklike", { type: "button", onclick: settings }, "Einstellungen"),
|
||||
el("button.linklike", { type: "button", onclick: onLogout }, "Abmelden"))),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Meine Listen"),
|
||||
state.lists.length
|
||||
? el("ul.lists", {}, state.lists.map(row))
|
||||
: el("p.empty", {}, "Noch keine Liste vorhanden."),
|
||||
el("label", { htmlFor: "new-list" }, "Neue Liste"),
|
||||
createRow())
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
186
web/html/js/views/manage.js
Normal file
186
web/html/js/views/manage.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// Märkte und Warengruppen pflegen.
|
||||
//
|
||||
// Die Reihenfolge wird gezogen, nicht getippt: `sort_order` verwaltet die
|
||||
// App selbst und schreibt nach jedem Verschieben 10, 20, 30 … zurück. Die
|
||||
// Abstände lassen Platz, falls später einmal einzeln eingefügt werden soll.
|
||||
"use strict";
|
||||
|
||||
import { del, get, post, put } from "../api.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
import { makeSortable } from "../sortable.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
const STEP = 10;
|
||||
|
||||
export async function manageView(root, { listId, back }) {
|
||||
let banner = null;
|
||||
let listName = "";
|
||||
|
||||
async function reload() {
|
||||
const [markets, categories, meta] = await Promise.all([
|
||||
get(`/api/lists/${listId}/markets`),
|
||||
get(`/api/lists/${listId}/categories`),
|
||||
get(`/api/lists/${listId}`),
|
||||
]);
|
||||
listName = meta.name;
|
||||
set({ markets, categories });
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function guarded(fn, { silent = false } = {}) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
if (!silent) say(err.message);
|
||||
await reload().catch(() => {});
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt die neue Reihenfolge zurück - nur die Einträge, deren Rang
|
||||
* sich tatsächlich geändert hat. */
|
||||
async function persistOrder(entries, endpoint, orderedIds) {
|
||||
const byId = new Map(entries.map((e) => [e.id, e]));
|
||||
const updates = [];
|
||||
|
||||
orderedIds.forEach((id, index) => {
|
||||
const entry = byId.get(id);
|
||||
const next = (index + 1) * STEP;
|
||||
if (entry && entry.sort_order !== next) {
|
||||
updates.push(put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
|
||||
name: entry.name,
|
||||
sort_order: next,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
if (!updates.length) return;
|
||||
await guarded(() => Promise.all(updates));
|
||||
}
|
||||
|
||||
function section({ title, entries, endpoint, hint, emptyText }) {
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
maxLength: 120,
|
||||
placeholder: "Name",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") add(); },
|
||||
});
|
||||
|
||||
function add() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) return;
|
||||
guarded(async () => {
|
||||
// Neues ans Ende: ein Schritt hinter dem letzten Rang.
|
||||
const last = entries.length ? Math.max(...entries.map((e) => e.sort_order)) : 0;
|
||||
await post(`/api/lists/${listId}/${endpoint}`, {
|
||||
name,
|
||||
sort_order: last + STEP,
|
||||
});
|
||||
nameField.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
const list = el("ul.sortable", {});
|
||||
|
||||
for (const entry of entries) {
|
||||
const handle = el("button.grip", {
|
||||
type: "button",
|
||||
title: "Zum Verschieben ziehen – oder mit den Pfeiltasten bewegen",
|
||||
"aria-label": `${entry.name} verschieben`,
|
||||
}, "⠿");
|
||||
|
||||
const name = el("input.entry-name", {
|
||||
type: "text",
|
||||
value: entry.name,
|
||||
maxLength: 120,
|
||||
onchange: (ev) => {
|
||||
const value = ev.target.value.trim();
|
||||
if (!value) {
|
||||
ev.target.value = entry.name;
|
||||
return;
|
||||
}
|
||||
guarded(() => put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
|
||||
name: value,
|
||||
sort_order: entry.sort_order,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const remove = el("button.danger.remove", {
|
||||
type: "button",
|
||||
title: `${entry.name} löschen`,
|
||||
"aria-label": `${entry.name} löschen`,
|
||||
onclick: () => {
|
||||
if (!confirm(`„${entry.name}“ löschen?`)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/${endpoint}/${entry.id}`));
|
||||
},
|
||||
}, "×");
|
||||
|
||||
list.append(el("li.sortable-row", { dataset: { id: entry.id } },
|
||||
handle, name, remove));
|
||||
}
|
||||
|
||||
if (entries.length > 1) {
|
||||
makeSortable(list, {
|
||||
handleSelector: ".grip",
|
||||
itemSelector: ".sortable-row",
|
||||
onReorder: (ids) => persistOrder(entries, endpoint, ids),
|
||||
});
|
||||
}
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, title),
|
||||
hint ? el("p.lead", {}, hint) : null,
|
||||
entries.length ? list : el("p.empty", {}, emptyText),
|
||||
el("label", { htmlFor: `add-${endpoint}` }, "Hinzufügen"),
|
||||
el("div.row", {},
|
||||
Object.assign(nameField, { id: `add-${endpoint}` }),
|
||||
el("button.primary", { type: "button", onclick: add }, "Anlegen"))
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back },
|
||||
"‹ Zurück zur Liste"),
|
||||
listName ? el("span.who", {}, listName) : null),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
section({
|
||||
title: "Märkte",
|
||||
entries: state.markets,
|
||||
endpoint: "markets",
|
||||
hint: "Die Reihenfolge bestimmt, in welcher Folge die Märkte in der " +
|
||||
"Liste und im Ausdruck erscheinen. Am Anfasser ziehen, um sie " +
|
||||
"zu ändern.",
|
||||
emptyText: "Noch kein Markt angelegt.",
|
||||
}),
|
||||
|
||||
section({
|
||||
title: "Warengruppen",
|
||||
entries: state.categories,
|
||||
endpoint: "categories",
|
||||
hint: "Innerhalb eines Marktes wird nach diesen Gruppen gegliedert; " +
|
||||
"die Artikel darin stehen alphabetisch.",
|
||||
emptyText: "Noch keine Warengruppe angelegt.",
|
||||
}),
|
||||
|
||||
el("p.footnote", {},
|
||||
"Ein gelöschter Markt nimmt keine Einträge mit – sie landen in der " +
|
||||
"Gruppe „Ohne Markt“.")
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
164
web/html/js/views/prices.js
Normal file
164
web/html/js/views/prices.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// Preisvergleich zwischen Märkten.
|
||||
//
|
||||
// Bewusst keine Matrix Artikel × Markt: Bei vier Märkten und dreißig
|
||||
// Artikeln wird die auf einem Telefon unlesbar. Stattdessen je Artikel
|
||||
// eine Karte mit den Märkten nach Preis sortiert - man sucht ohnehin
|
||||
// "wo ist X am günstigsten", nicht "wie sieht die ganze Tabelle aus".
|
||||
"use strict";
|
||||
|
||||
import { get } from "../api.js";
|
||||
import { el, euro, formatQuantity, mount } from "../dom.js";
|
||||
|
||||
let expandedId = null;
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Preis je Einheit, geliefert in Zehntelcent. */
|
||||
function unitPrice(deci, unit) {
|
||||
if (!deci || !unit) return null;
|
||||
return `${euro(deci / 10)} je ${unit}`;
|
||||
}
|
||||
|
||||
export async function pricesView(root, { listId, back }) {
|
||||
let data = null;
|
||||
let banner = null;
|
||||
let detail = null;
|
||||
|
||||
async function reload() {
|
||||
data = await get(`/api/lists/${listId}/prices`);
|
||||
}
|
||||
|
||||
function say(message) {
|
||||
banner = message;
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function toggle(articleId) {
|
||||
if (expandedId === articleId) {
|
||||
expandedId = null;
|
||||
detail = null;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
expandedId = articleId;
|
||||
detail = null;
|
||||
render();
|
||||
try {
|
||||
detail = await get(`/api/lists/${listId}/articles/${articleId}/prices`);
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function marketRow(row, marketId, cents, isBest) {
|
||||
return el("div", { className: `price-row${isBest ? " best" : ""}` },
|
||||
el("span.market", {}, data.market_names[marketId] || "?"),
|
||||
el("span.amount", {}, euro(cents)),
|
||||
isBest && row.spread_cents > 0
|
||||
? el("span.badge", {}, "günstigster")
|
||||
: row.spread_cents > 0
|
||||
? el("span.diff", {}, `+${euro(cents - row.best_cents)}`)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function detailBlock() {
|
||||
if (!detail) return el("p.loading", {}, "Wird geladen …");
|
||||
|
||||
return el("div.price-detail", {},
|
||||
el("h4", {}, "Beobachtungen je Markt"),
|
||||
el("ul.price-markets", {}, detail.markets.map((m) =>
|
||||
el("li", {},
|
||||
el("span.market", {}, m.market_name),
|
||||
el("span.sub", {},
|
||||
m.latest_pack_size
|
||||
? `${formatQuantity(m.latest_pack_size)} ${m.latest_pack_unit || ""} · `
|
||||
: "",
|
||||
unitPrice(m.unit_price_deci, m.latest_pack_unit)
|
||||
? `${unitPrice(m.unit_price_deci, m.latest_pack_unit)} · `
|
||||
: "",
|
||||
m.observations === 1
|
||||
? "einmal erfasst"
|
||||
: `${m.observations}× erfasst, ${euro(m.min_cents)}–${euro(m.max_cents)}`,
|
||||
` · zuletzt ${formatDate(m.latest_at)}`),
|
||||
el("span.amount", {}, euro(m.latest_cents))))),
|
||||
|
||||
detail.history.length > 1
|
||||
? el("details.price-history", {},
|
||||
el("summary", {}, `Verlauf (${detail.history.length} Einträge)`),
|
||||
el("ul", {}, detail.history.map((h) =>
|
||||
el("li", {},
|
||||
`${formatDate(h.recorded_at)} · ${h.market_name} · ${euro(h.price_cents)}`,
|
||||
h.pack_size
|
||||
? ` (${formatQuantity(h.pack_size)} ${h.pack_unit || ""})` : ""))))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function articleCard(row) {
|
||||
const entries = Object.entries(row.prices).sort((a, b) => a[1] - b[1]);
|
||||
|
||||
return el("section.card.price-card", {},
|
||||
el("button.price-head", {
|
||||
type: "button",
|
||||
onclick: () => toggle(row.article_id),
|
||||
"aria-expanded": expandedId === row.article_id ? "true" : "false",
|
||||
},
|
||||
el("span.name", {}, row.article_name),
|
||||
row.spread_cents > 0
|
||||
? el("span.spread", {}, `bis ${euro(row.spread_cents)} Unterschied`)
|
||||
: el("span.spread", {}, "überall gleich")),
|
||||
|
||||
el("div.price-rows", {}, entries.map(([marketId, cents]) =>
|
||||
marketRow(row, marketId, cents, row.best_market_ids.includes(marketId)))),
|
||||
|
||||
expandedId === row.article_id ? detailBlock() : null
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const withSpread = data.rows.filter((r) => r.spread_cents > 0);
|
||||
const savings = withSpread.reduce((sum, r) => sum + r.spread_cents, 0);
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back },
|
||||
"‹ Zurück zur Liste")),
|
||||
|
||||
banner ? el("p.error", {}, banner) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Preise"),
|
||||
data.rows.length
|
||||
? el("p.lead", {},
|
||||
`${data.rows.length} Artikel mit erfassten Preisen. `,
|
||||
withSpread.length
|
||||
? `Bei ${withSpread.length} davon unterscheiden sich die Märkte – ` +
|
||||
`zusammen ${euro(savings)} Unterschied, wenn jeder Artikel ` +
|
||||
`im günstigsten Markt gekauft würde.`
|
||||
: "Bislang gibt es keine Preisunterschiede zwischen den Märkten.")
|
||||
: el("p.empty", {},
|
||||
"Noch keine Preise erfasst. Trag beim Einkaufen Preise an den " +
|
||||
"Artikeln ein – sobald ein Eintrag einem Markt zugeordnet ist, " +
|
||||
"wird der Preis hier gesammelt.")),
|
||||
|
||||
data.rows.map(articleCard),
|
||||
|
||||
data.rows.length
|
||||
? el("p.footnote", {},
|
||||
"Erfasst werden Markt, Artikel, Betrag und Zeitpunkt – ohne " +
|
||||
"Angabe, wer den Preis eingetragen hat.")
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
143
web/html/js/views/public.js
Normal file
143
web/html/js/views/public.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Öffentliche Ansicht einer Liste über /s/<token>. Kein Konto nötig.
|
||||
// Nur ansehen und - sofern erlaubt - abhaken. Auf Druck ausgelegt.
|
||||
"use strict";
|
||||
|
||||
import { get, post } from "../api.js";
|
||||
import { clear, el, euro, formatPack, mount } from "../dom.js";
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export async function publicView(root, { token }) {
|
||||
let view = null;
|
||||
let banner = null;
|
||||
|
||||
async function load() {
|
||||
view = await get(`/api/public/${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
function itemRow(item) {
|
||||
const bought = item.status === "bought";
|
||||
|
||||
const check = view.allow_check
|
||||
? el("button.check", {
|
||||
type: "button",
|
||||
"aria-pressed": bought ? "true" : "false",
|
||||
title: bought ? "Als offen markieren" : "Als gekauft markieren",
|
||||
onclick: async () => {
|
||||
const next = bought ? "open" : "bought";
|
||||
item.status = next; // sofort anzeigen
|
||||
render();
|
||||
try {
|
||||
await post(`/api/public/${encodeURIComponent(token)}/items/${item.id}`,
|
||||
{ status: next });
|
||||
await load();
|
||||
} catch (err) {
|
||||
await load().catch(() => {});
|
||||
say(err.message);
|
||||
}
|
||||
render();
|
||||
},
|
||||
}, bought ? "✓" : "")
|
||||
: el("span.check.readonly", { "aria-hidden": "true" }, bought ? "✓" : "");
|
||||
|
||||
const pack = formatPack(item.pack_size, item.pack_unit);
|
||||
|
||||
return el("li", { className: `item${bought ? " bought" : ""}` },
|
||||
el("div.item-main", {},
|
||||
check,
|
||||
el("span", {
|
||||
className: `item-count${item.count > 1 ? " many" : ""}`,
|
||||
}, `${item.count || 1}\u00d7`),
|
||||
el("div.item-text", {},
|
||||
el("span.name", {}, item.article_name),
|
||||
pack || item.variant || item.note
|
||||
? el("span.sub", {},
|
||||
[pack, item.variant, item.note].filter(Boolean).join(" · "))
|
||||
: null),
|
||||
item.price_cents
|
||||
? el("span.price-static", {},
|
||||
euro(item.total_cents ?? item.price_cents))
|
||||
: null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.public-head", {},
|
||||
el("h1", {}, view.list_name),
|
||||
el("p.sub", {},
|
||||
"Geteilte Ansicht",
|
||||
view.allow_check ? " – du kannst Artikel abhaken." : " – nur zum Ansehen.",
|
||||
` Gültig bis ${formatDate(view.expires_at)}.`)
|
||||
),
|
||||
banner ? el("p.error", {}, banner.message) : null,
|
||||
|
||||
view.markets.length
|
||||
? view.markets.map((market) =>
|
||||
el("section.market", {},
|
||||
el("h2", {},
|
||||
el("span", {}, market.market_name),
|
||||
el("span.market-meta", {},
|
||||
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
|
||||
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
|
||||
market.categories.map((cat) =>
|
||||
el("div.category", {},
|
||||
el("h3", {}, cat.category_name),
|
||||
el("ul.items", {}, cat.items.map(itemRow))))))
|
||||
: el("p.empty", {}, "Die Liste ist leer."),
|
||||
|
||||
el("footer.summary", {},
|
||||
el("div.total", {},
|
||||
el("span", {}, "Gesamt"),
|
||||
el("strong", {}, euro(view.grand_total_cents)))),
|
||||
|
||||
el("p.footnote.no-print", {},
|
||||
// Eigene Druckseite statt window.print(): Sie ist auf A4
|
||||
// ausgelegt und funktioniert auch, wenn der Empfänger die
|
||||
// Ansicht nur weiterleiten will.
|
||||
el("a.linklike", {
|
||||
href: `/api/public/${encodeURIComponent(token)}/print`,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
}, "Drucken"),
|
||||
" · ",
|
||||
el("button.linklike", {
|
||||
type: "button",
|
||||
onclick: async () => {
|
||||
try {
|
||||
await load();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
},
|
||||
}, "Aktualisieren"))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch (err) {
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Liste nicht verfügbar"),
|
||||
el("p.error", {}, err.message),
|
||||
el("p.lead", {},
|
||||
"Bitte die Person, die den Link geteilt hat, um einen neuen."))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
110
web/html/js/views/scan-result.js
Normal file
110
web/html/js/views/scan-result.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// Bestätigungsdialog nach einem Scan.
|
||||
//
|
||||
// Ersetzt das frühere prompt(): Dort ließ sich nur ein Name eingeben,
|
||||
// und die Herkunft der Angaben war nicht erkennbar. Wenn Daten aus einer
|
||||
// fremden Quelle vorbelegt werden, soll man sehen, woher sie stammen -
|
||||
// und sie ändern können, bevor sie im eigenen Bestand landen.
|
||||
"use strict";
|
||||
|
||||
import { el } from "../dom.js";
|
||||
|
||||
/**
|
||||
* @param {object} lookup Antwort von /api/lists/{id}/barcode/{code}
|
||||
* @returns {Promise<{name: string, quantity: string, unit: string, variant: string}|null>}
|
||||
*/
|
||||
export function confirmScan(lookup) {
|
||||
return new Promise((resolve) => {
|
||||
const known = lookup.found && lookup.source === "openfoodfacts";
|
||||
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
value: lookup.name || "",
|
||||
placeholder: "Wie heißt der Artikel?",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") accept(); },
|
||||
});
|
||||
const countField = el("input.count", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
value: lookup.count != null ? String(lookup.count) : "",
|
||||
placeholder: "Anz.",
|
||||
title: "Stückzahl",
|
||||
});
|
||||
const qtyField = el("input.qty", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
value: lookup.pack_size != null
|
||||
? String(lookup.pack_size).replace(".", ",") : "",
|
||||
placeholder: "Gebinde",
|
||||
title: "Packungsgröße",
|
||||
});
|
||||
const unitField = el("input.unit", {
|
||||
type: "text",
|
||||
maxLength: 32,
|
||||
value: lookup.pack_unit || "",
|
||||
placeholder: "Einheit",
|
||||
});
|
||||
const variantField = el("input.variant", {
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
value: lookup.brand || "",
|
||||
placeholder: "Eigenschaft",
|
||||
});
|
||||
|
||||
function accept() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) {
|
||||
nameField.focus();
|
||||
return;
|
||||
}
|
||||
close({
|
||||
name,
|
||||
count: countField.value,
|
||||
packSize: qtyField.value,
|
||||
packUnit: unitField.value.trim(),
|
||||
variant: variantField.value.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const dialog = el("div.modal", {},
|
||||
el("div.modal-card", {},
|
||||
el("h2", {}, known ? "Produkt gefunden" : "Neuer Artikel"),
|
||||
el("p.sub", {}, `Strichcode ${lookup.barcode}`),
|
||||
|
||||
known
|
||||
? el("p.source-note", {},
|
||||
"Vorgeschlagen aus Open Food Facts",
|
||||
lookup.package ? ` – Packung: ${lookup.package}` : "",
|
||||
". Bitte prüfen und bei Bedarf anpassen; übernommen wird, " +
|
||||
"was hier steht.")
|
||||
: el("p.lead", {},
|
||||
"Zu diesem Code liegen keine Angaben vor. Trag den Namen ein – " +
|
||||
"beim nächsten Scan wird der Artikel dann sofort gefunden."),
|
||||
|
||||
el("label", {}, "Name"), nameField,
|
||||
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
|
||||
|
||||
el("div.menu-actions", {},
|
||||
el("button.primary", { type: "button", onclick: accept }, "Hinzufügen"),
|
||||
el("button", { type: "button", onclick: () => close(null) }, "Abbrechen"))
|
||||
)
|
||||
);
|
||||
|
||||
function close(result) {
|
||||
dialog.remove();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.classList.remove("modal-open");
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onKey(ev) {
|
||||
if (ev.key === "Escape") close(null);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.classList.add("modal-open");
|
||||
document.body.append(dialog);
|
||||
nameField.focus();
|
||||
nameField.select();
|
||||
});
|
||||
}
|
||||
171
web/html/js/views/scanner.js
Normal file
171
web/html/js/views/scanner.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Kamerasucher für Strichcodes.
|
||||
//
|
||||
// Zwei Wege, in dieser Reihenfolge:
|
||||
// 1. BarcodeDetector - nativ in Chrome und auf Android, erkennt auch
|
||||
// QR- und Datamatrix-Codes, läuft außerhalb des Hauptthreads.
|
||||
// 2. Eigener Decoder aus barcode.js - überall sonst, insbesondere
|
||||
// Safari auf iPhone und iPad.
|
||||
//
|
||||
// Wenn beides ausfällt (keine Kamera, Zugriff verweigert), bleibt die
|
||||
// Eingabe von Hand. Die Ziffernfolge steht unter jedem Strichcode.
|
||||
"use strict";
|
||||
|
||||
import { decodeImage } from "../barcode.js";
|
||||
import { clear, el } from "../dom.js";
|
||||
|
||||
const SCAN_INTERVAL_MS = 120;
|
||||
|
||||
async function nativeDetector() {
|
||||
if (!("BarcodeDetector" in window)) return null;
|
||||
try {
|
||||
const formats = await window.BarcodeDetector.getSupportedFormats();
|
||||
const wanted = ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "qr_code"]
|
||||
.filter((f) => formats.includes(f));
|
||||
if (!wanted.length) return null;
|
||||
return new window.BarcodeDetector({ formats: wanted });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet den Sucher als Überlagerung.
|
||||
* @returns {Promise<string|null>} erkannte Ziffernfolge oder null bei Abbruch
|
||||
*/
|
||||
export function scanBarcode() {
|
||||
return new Promise((resolve) => {
|
||||
let stream = null;
|
||||
let timer = null;
|
||||
let closed = false;
|
||||
|
||||
const video = el("video", {
|
||||
playsInline: true,
|
||||
muted: true,
|
||||
autoplay: true,
|
||||
className: "scan-video",
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
const hint = el("p.scan-hint", {}, "Kamera wird geöffnet …");
|
||||
|
||||
const manualField = el("input", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
placeholder: "Ziffern unter dem Strichcode",
|
||||
maxLength: 32,
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") useManual(); },
|
||||
});
|
||||
|
||||
function useManual() {
|
||||
const value = manualField.value.replace(/\D/g, "");
|
||||
if (value.length < 6) {
|
||||
hint.textContent = "Bitte mindestens sechs Ziffern eingeben.";
|
||||
return;
|
||||
}
|
||||
finish(value);
|
||||
}
|
||||
|
||||
const overlay = el("div.scan-overlay", {},
|
||||
el("div.scan-stage", {}, video, el("div.scan-frame", {})),
|
||||
hint,
|
||||
el("div.scan-manual", {},
|
||||
el("label", {}, "Oder von Hand eingeben"),
|
||||
el("div.row", {}, manualField,
|
||||
el("button.primary", { type: "button", onclick: useManual }, "Übernehmen"))),
|
||||
el("button.secondary.scan-close", { type: "button", onclick: () => finish(null) },
|
||||
"Abbrechen")
|
||||
);
|
||||
|
||||
function finish(code) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (timer) clearInterval(timer);
|
||||
// Kamera zuverlässig freigeben - sonst leuchtet die Anzeigeleuchte
|
||||
// weiter und der Akku leidet.
|
||||
if (stream) for (const track of stream.getTracks()) track.stop();
|
||||
overlay.remove();
|
||||
document.body.classList.remove("scanning");
|
||||
window.removeEventListener("keydown", onKey);
|
||||
resolve(code);
|
||||
}
|
||||
|
||||
function onKey(ev) {
|
||||
if (ev.key === "Escape") finish(null);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.classList.add("scanning");
|
||||
document.body.append(overlay);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: "environment" },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
} catch (err) {
|
||||
hint.textContent =
|
||||
err.name === "NotAllowedError"
|
||||
? "Kein Zugriff auf die Kamera. Bitte in den Browsereinstellungen erlauben – oder die Ziffern von Hand eingeben."
|
||||
: "Keine Kamera verfügbar. Bitte die Ziffern von Hand eingeben.";
|
||||
manualField.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
video.srcObject = stream;
|
||||
try {
|
||||
await video.play();
|
||||
} catch {
|
||||
// Manche Browser verlangen eine Nutzeraktion; das Bild erscheint
|
||||
// dann trotzdem, sobald die Wiedergabe anläuft.
|
||||
}
|
||||
|
||||
const detector = await nativeDetector();
|
||||
hint.textContent = detector
|
||||
? "Strichcode in den Rahmen halten."
|
||||
: "Strichcode in den Rahmen halten. Für eine gute Erkennung waagerecht und gut ausgeleuchtet.";
|
||||
|
||||
timer = setInterval(async () => {
|
||||
if (closed || video.readyState < 2) return;
|
||||
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
if (!width || !height) return;
|
||||
|
||||
if (detector) {
|
||||
try {
|
||||
const found = await detector.detect(video);
|
||||
if (found.length) {
|
||||
const value = String(found[0].rawValue || "").trim();
|
||||
if (value) return finish(value);
|
||||
}
|
||||
} catch {
|
||||
// Weiter mit dem eigenen Decoder.
|
||||
}
|
||||
}
|
||||
|
||||
// Nur den mittleren Streifen auswerten: Dort liegt der Code,
|
||||
// und es spart die meiste Rechenzeit.
|
||||
const bandHeight = Math.round(height * 0.35);
|
||||
const y = Math.round((height - bandHeight) / 2);
|
||||
canvas.width = width;
|
||||
canvas.height = bandHeight;
|
||||
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
context.drawImage(video, 0, y, width, bandHeight, 0, 0, width, bandHeight);
|
||||
|
||||
const code = decodeImage(context.getImageData(0, 0, width, bandHeight));
|
||||
if (code) finish(code);
|
||||
}, SCAN_INTERVAL_MS);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** Ob ein Sucher überhaupt sinnvoll ist. Über reines HTTP verweigert der
|
||||
* Browser den Kamerazugriff - dann direkt zur Eingabe von Hand. */
|
||||
export function cameraAvailable() {
|
||||
return Boolean(navigator.mediaDevices?.getUserMedia) && window.isSecureContext;
|
||||
}
|
||||
233
web/html/js/views/settings.js
Normal file
233
web/html/js/views/settings.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// Einstellungen des angemeldeten Kontos: Anzeigename, Passwort,
|
||||
// Benachrichtigungen.
|
||||
"use strict";
|
||||
|
||||
import { del, patch, post } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
import * as push from "../push.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export async function settingsView(root, { back, admin }) {
|
||||
let banner = null;
|
||||
let pushStatus = null;
|
||||
let devices = [];
|
||||
|
||||
async function reload() {
|
||||
pushStatus = await push.status();
|
||||
devices = pushStatus.serverEnabled
|
||||
? await push.listDevices().catch(() => [])
|
||||
: [];
|
||||
}
|
||||
|
||||
function say(message, kind = "notice") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 8000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Benachrichtigungen
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function pushSection() {
|
||||
if (!pushStatus.serverEnabled) {
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead", {},
|
||||
"Auf diesem Server sind Push-Benachrichtigungen nicht eingerichtet. " +
|
||||
"Dafür wird ein VAPID-Schlüsselpaar in der Konfiguration benötigt."));
|
||||
}
|
||||
|
||||
// Auf iOS gibt es Push nur in der installierten App - das ist eine
|
||||
// Einschränkung von Apple, kein Fehler der Anwendung.
|
||||
if (pushStatus.needsInstall) {
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead.warn", {},
|
||||
"Auf iPhone und iPad funktionieren Benachrichtigungen nur, wenn die " +
|
||||
"App über „Teilen → Zum Home-Bildschirm“ installiert wurde. " +
|
||||
"Öffne sie danach über das Symbol auf dem Startbildschirm und " +
|
||||
"komm hierher zurück."));
|
||||
}
|
||||
|
||||
if (!pushStatus.supported) {
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead", {},
|
||||
"Dieser Browser unterstützt keine Push-Benachrichtigungen. " +
|
||||
"Über eine unverschlüsselte Verbindung sind sie ebenfalls nicht " +
|
||||
"möglich – dann hilft der Zugriff über HTTPS."));
|
||||
}
|
||||
|
||||
const blocked = pushStatus.permission === "denied";
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead", {},
|
||||
"Wenn jemand eine geteilte Liste ändert, bekommst du eine Meldung – ",
|
||||
el("strong", {}, `höchstens eine je Liste alle ${pushStatus.throttleHours} Stunden`),
|
||||
". Wer im Laden steht und abhakt, erzeugt sonst im Minutentakt " +
|
||||
"Benachrichtigungen."),
|
||||
|
||||
blocked
|
||||
? el("p.lead.warn", {},
|
||||
"Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt " +
|
||||
"sich nur in den Einstellungen des Browsers wieder ändern – " +
|
||||
"meist über das Schloss- oder Info-Symbol in der Adresszeile.")
|
||||
: null,
|
||||
|
||||
pushStatus.subscribed
|
||||
? el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await push.sendTest();
|
||||
say("Testnachricht unterwegs. Kommt sie nicht an, steht der " +
|
||||
"Grund im Log des api-Containers.");
|
||||
}),
|
||||
}, "Testnachricht senden"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await push.unsubscribe();
|
||||
say("Dieses Gerät bekommt keine Benachrichtigungen mehr.");
|
||||
}),
|
||||
}, "Auf diesem Gerät abschalten"))
|
||||
: el("button.primary", {
|
||||
type: "button",
|
||||
disabled: blocked,
|
||||
onclick: () => guarded(async () => {
|
||||
const result = await push.subscribe();
|
||||
if (!result.ok) {
|
||||
say(result.reason, "error");
|
||||
return;
|
||||
}
|
||||
say("Benachrichtigungen für dieses Gerät eingeschaltet.");
|
||||
}),
|
||||
}, "Auf diesem Gerät einschalten"),
|
||||
|
||||
devices.length
|
||||
? el("div", {},
|
||||
el("h3.device-heading", {}, "Angemeldete Geräte"),
|
||||
el("ul.share-list", {}, devices.map((device) =>
|
||||
el("li", {},
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, device.label || "Unbenanntes Gerät"),
|
||||
el("span.sub", {},
|
||||
`seit ${formatDate(device.created_at)}`,
|
||||
device.last_success_at
|
||||
? ` · zuletzt erreicht ${formatDate(device.last_success_at)}`
|
||||
: " · noch nichts zugestellt")),
|
||||
el("div.member-actions", {},
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() =>
|
||||
del(`/api/push/subscriptions/${device.id}`)),
|
||||
}, "Entfernen"))))))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Profil und Passwort
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function profileSection() {
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
value: state.user.display_name || "",
|
||||
maxLength: 80,
|
||||
placeholder: "wird anderen Mitgliedern angezeigt",
|
||||
});
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Profil"),
|
||||
el("p.lead", {}, state.user.email),
|
||||
el("label", {}, "Anzeigename ", el("span.hint", {}, "(freiwillig)")),
|
||||
el("div.row", {}, nameField,
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
const user = await patch("/api/auth/me", {
|
||||
display_name: nameField.value.trim() || null,
|
||||
});
|
||||
set({ user });
|
||||
say("Gespeichert.");
|
||||
}),
|
||||
}, "Speichern"))
|
||||
);
|
||||
}
|
||||
|
||||
function passwordSection() {
|
||||
const current = el("input", { type: "password", autocomplete: "current-password" });
|
||||
const next = el("input", { type: "password", autocomplete: "new-password" });
|
||||
const repeat = el("input", { type: "password", autocomplete: "new-password" });
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Passwort ändern"),
|
||||
el("p.lead", {},
|
||||
"Nach der Änderung werden alle anderen Geräte abgemeldet."),
|
||||
el("label", {}, "Bisheriges Passwort"), current,
|
||||
el("label", {}, "Neues Passwort ", el("span.hint", {}, "(mindestens 12 Zeichen)")), next,
|
||||
el("label", {}, "Wiederholen"), repeat,
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
if (next.value !== repeat.value) {
|
||||
throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||
}
|
||||
const result = await post("/api/auth/password/change", {
|
||||
current_password: current.value,
|
||||
new_password: next.value,
|
||||
});
|
||||
current.value = next.value = repeat.value = "";
|
||||
say(result.detail);
|
||||
}),
|
||||
}, "Passwort ändern")
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück"),
|
||||
el("span.who", {}, state.user.display_name || state.user.email)),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einstellungen"),
|
||||
// Nur für Administratoren sichtbar - der Endpunkt prüft
|
||||
// zusätzlich, die Oberfläche allein wäre kein Schutz.
|
||||
state.user.is_admin
|
||||
? el("div", {},
|
||||
el("p.lead", {}, "Du hast Administratorrechte."),
|
||||
el("button.secondary", { type: "button", onclick: admin },
|
||||
"Benutzerverwaltung"))
|
||||
: null),
|
||||
pushSection(),
|
||||
profileSection(),
|
||||
passwordSection()
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
468
web/html/js/views/share.js
Normal file
468
web/html/js/views/share.js
Normal file
@@ -0,0 +1,468 @@
|
||||
// Teilen einer Liste: Einladen, erneut senden, widerrufen, Zugriff
|
||||
// entziehen, Eigentum übertragen. Nur für den Eigentümer erreichbar.
|
||||
"use strict";
|
||||
|
||||
import { del, get, post, put } from "../api.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
|
||||
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: "offen",
|
||||
accepted: "angenommen",
|
||||
revoked: "widerrufen",
|
||||
expired: "abgelaufen",
|
||||
};
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareView(root, { listId, back }) {
|
||||
let list = null;
|
||||
let members = [];
|
||||
let invites = [];
|
||||
let banner = null;
|
||||
|
||||
let publicLinks = [];
|
||||
// Der Klartext eines neu erzeugten Links - nur bis zum nächsten Neuzeichnen
|
||||
// im Speicher, danach nicht mehr rekonstruierbar.
|
||||
let freshLink = null;
|
||||
|
||||
async function reload() {
|
||||
[list, members, invites, publicLinks] = await Promise.all([
|
||||
get(`/api/lists/${listId}`),
|
||||
get(`/api/lists/${listId}/members`),
|
||||
get(`/api/lists/${listId}/invites`),
|
||||
get(`/api/lists/${listId}/public-links`),
|
||||
]);
|
||||
}
|
||||
|
||||
function say(message, kind = "notice") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 8000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
const result = await fn();
|
||||
await reload();
|
||||
render();
|
||||
if (result && result.detail) say(result.detail);
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Einladen ----------------
|
||||
|
||||
function inviteForm() {
|
||||
const email = el("input", {
|
||||
type: "email",
|
||||
autocomplete: "off",
|
||||
placeholder: "person@example.de",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") submit(); },
|
||||
});
|
||||
const role = el("select", {},
|
||||
el("option", { value: "editor" }, "Bearbeiter – darf ändern"),
|
||||
el("option", { value: "viewer" }, "Nur lesen")
|
||||
);
|
||||
const button = el("button.primary", { type: "button", onclick: () => submit() },
|
||||
"Einladen");
|
||||
|
||||
async function submit() {
|
||||
const value = email.value.trim();
|
||||
if (!value) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await post(`/api/lists/${listId}/invites`, { email: value, role: role.value });
|
||||
email.value = "";
|
||||
await reload();
|
||||
render();
|
||||
say(`Einladung an ${value} versendet.`);
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Person einladen"),
|
||||
el("p.lead", {},
|
||||
"Die Einladung geht per E-Mail an die angegebene Adresse und gilt nur " +
|
||||
"für diese. Ein Konto braucht die Person noch nicht – sie kann sich " +
|
||||
"beim Öffnen des Links eines anlegen."),
|
||||
el("label", {}, "E-Mail-Adresse"), email,
|
||||
el("label", {}, "Berechtigung"), role,
|
||||
button
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Mitglieder ----------------
|
||||
|
||||
function memberRow(member) {
|
||||
const isOwner = member.role === "owner";
|
||||
const name = member.display_name || member.email;
|
||||
|
||||
const roleSelect = isOwner ? null : el("select.role", {
|
||||
onchange: (ev) => guarded(() =>
|
||||
put(`/api/lists/${listId}/members/${member.user_id}`, {
|
||||
role: ev.target.value,
|
||||
may_share_public: member.may_share_public,
|
||||
})),
|
||||
},
|
||||
el("option", { value: "editor", selected: member.role === "editor" },
|
||||
"Bearbeiter"),
|
||||
el("option", { value: "viewer", selected: member.role === "viewer" },
|
||||
"Nur lesen")
|
||||
);
|
||||
|
||||
// Zusatzrecht, unabhängig von der Rolle.
|
||||
const shareRight = isOwner ? null : el("label.checkline", {},
|
||||
el("input", {
|
||||
type: "checkbox",
|
||||
checked: member.may_share_public,
|
||||
onchange: (ev) => guarded(() =>
|
||||
put(`/api/lists/${listId}/members/${member.user_id}`, {
|
||||
role: member.role,
|
||||
may_share_public: ev.target.checked,
|
||||
})),
|
||||
}),
|
||||
el("span", {}, "darf öffentliche Links erzeugen")
|
||||
);
|
||||
|
||||
return el("li", {},
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, name),
|
||||
el("span.sub", {},
|
||||
member.display_name ? `${member.email} · ` : "",
|
||||
isOwner ? ROLE_LABEL.owner : `dabei seit ${formatDate(member.joined_at)}`)
|
||||
),
|
||||
roleSelect,
|
||||
shareRight,
|
||||
isOwner ? null : el("div.member-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
title: "Eigentum übertragen",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Eigentum an „${list.name}“ auf ${name} übertragen?\n\n` +
|
||||
"Du wirst dabei zum Bearbeiter und kannst die Freigabe " +
|
||||
"danach nicht mehr verwalten."
|
||||
)) return;
|
||||
guarded(() => post(`/api/lists/${listId}/transfer`,
|
||||
{ user_id: member.user_id }));
|
||||
},
|
||||
}, "Eigentum übertragen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(`${name} den Zugriff auf „${list.name}“ entziehen?`)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/members/${member.user_id}`));
|
||||
},
|
||||
}, "Zugriff entziehen")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Einladungen ----------------
|
||||
|
||||
function inviteRow(invite) {
|
||||
const open = invite.status === "pending";
|
||||
const done = invite.status === "accepted";
|
||||
|
||||
return el("li", { className: `invite ${invite.status}` },
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, invite.email),
|
||||
el("span.sub", {},
|
||||
`${ROLE_LABEL[invite.role]} · ${STATUS_LABEL[invite.status]}`,
|
||||
open ? ` bis ${formatDate(invite.expires_at)}` : "",
|
||||
invite.send_count > 1 ? ` · ${invite.send_count}× gesendet` : "")
|
||||
),
|
||||
done ? null : el("div.member-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() =>
|
||||
post(`/api/lists/${listId}/invites/${invite.id}/resend`)),
|
||||
}, invite.status === "revoked" || invite.status === "expired"
|
||||
? "Neu senden"
|
||||
: "Erneut senden"),
|
||||
open ? el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Einladung an ${invite.email} widerrufen?\n\n` +
|
||||
"Der bereits versendete Link funktioniert danach nicht mehr."
|
||||
)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/invites/${invite.id}`));
|
||||
},
|
||||
}, "Widerrufen") : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Öffentliche Links ----------------
|
||||
|
||||
function defaultExpiry(days) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(23, 59, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toDateInput(date) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function publicSection() {
|
||||
const label = el("input", {
|
||||
type: "text",
|
||||
maxLength: 120,
|
||||
placeholder: "z. B. „für Oma“ – nur zur Unterscheidung",
|
||||
});
|
||||
const until = el("input", {
|
||||
type: "date",
|
||||
value: toDateInput(defaultExpiry(14)),
|
||||
min: toDateInput(defaultExpiry(1)),
|
||||
max: toDateInput(defaultExpiry(365)),
|
||||
});
|
||||
const allowCheck = el("input", { type: "checkbox", checked: true });
|
||||
const button = el("button.primary", { type: "button", onclick: () => create() },
|
||||
"Link erzeugen");
|
||||
|
||||
async function create() {
|
||||
if (!until.value) {
|
||||
say("Bitte ein Ablaufdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
const expires = new Date(until.value);
|
||||
expires.setHours(23, 59, 0, 0);
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await post(`/api/lists/${listId}/public-links`, {
|
||||
label: label.value.trim() || null,
|
||||
expires_at: expires.toISOString(),
|
||||
allow_check: allowCheck.checked,
|
||||
});
|
||||
freshLink = result.url;
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const active = publicLinks.filter((l) => l.status === "active");
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Öffentlicher Link"),
|
||||
el("p.lead", {},
|
||||
"Wer den Link hat, kann die Liste ansehen – ohne Konto. Namen der " +
|
||||
"Personen, die Artikel eingetragen haben, werden dabei nicht " +
|
||||
"angezeigt. Behandle den Link wie einen Schlüssel: Weitergabe " +
|
||||
"bedeutet Zugriff."),
|
||||
|
||||
freshLink ? el("div.fresh-link", {},
|
||||
el("p", {}, el("strong", {}, "Der Link – jetzt kopieren:")),
|
||||
el("input.linkfield", {
|
||||
type: "text",
|
||||
value: freshLink,
|
||||
readOnly: true,
|
||||
onclick: (ev) => ev.target.select(),
|
||||
}),
|
||||
el("div.member-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: async (ev) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(freshLink);
|
||||
ev.target.textContent = "Kopiert";
|
||||
} catch {
|
||||
say("Kopieren nicht möglich – bitte von Hand markieren.", "error");
|
||||
}
|
||||
},
|
||||
}, "In die Zwischenablage"),
|
||||
el("button", { type: "button", onclick: () => { freshLink = null; render(); } },
|
||||
"Ausblenden")),
|
||||
el("p.sub", {},
|
||||
"Aus Sicherheitsgründen steht in der Datenbank nur ein Prüfwert. " +
|
||||
"Später lässt sich die Adresse nicht mehr anzeigen – dann bleibt " +
|
||||
"nur, einen neuen Link zu erzeugen.")
|
||||
) : null,
|
||||
|
||||
publicLinks.length
|
||||
? el("ul.share-list", {}, publicLinks.map(publicLinkRow))
|
||||
: el("p.empty", {}, "Noch kein Link erzeugt."),
|
||||
|
||||
el("label", {}, "Bezeichnung ", el("span.hint", {}, "(freiwillig)")), label,
|
||||
el("label", {}, "Gültig bis"), until,
|
||||
el("label.checkline", {}, allowCheck,
|
||||
el("span", {}, "Abhaken erlauben (sonst nur ansehen)")),
|
||||
button,
|
||||
|
||||
active.length > 1
|
||||
? el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(`Alle ${active.length} aktiven Links widerrufen?`)) return;
|
||||
guarded(() => post(`/api/lists/${listId}/public-links/revoke-all`));
|
||||
},
|
||||
}, "Alle Links widerrufen")
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function publicLinkRow(link) {
|
||||
const STATE = { active: "aktiv", expired: "abgelaufen", revoked: "widerrufen" };
|
||||
return el("li", { className: `invite ${link.status === "active" ? "" : "revoked"}` },
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, link.label || "Ohne Bezeichnung"),
|
||||
el("span.sub", {},
|
||||
`${STATE[link.status]} · gültig bis ${formatDate(link.expires_at)}`,
|
||||
link.allow_check ? " · Abhaken erlaubt" : " · nur ansehen",
|
||||
link.access_count
|
||||
? ` · ${link.access_count}× geöffnet, zuletzt ${formatDate(link.last_access_at)}`
|
||||
: " · noch nicht geöffnet")
|
||||
),
|
||||
link.status === "revoked" ? null : el("div.member-actions", {},
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
"Diesen Link widerrufen?\n\n" +
|
||||
"Er funktioniert danach für alle nicht mehr, die ihn haben."
|
||||
)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/public-links/${link.id}`));
|
||||
},
|
||||
}, "Widerrufen"))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Gesamtansicht ----------------
|
||||
|
||||
function render() {
|
||||
const shared = members.filter((m) => m.role !== "owner");
|
||||
const openInvites = invites.filter((i) => i.status === "pending");
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück zur Liste")
|
||||
),
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Teilen"),
|
||||
el("p.lead", {},
|
||||
`„${list.name}“ ist derzeit `,
|
||||
shared.length === 0 && openInvites.length === 0
|
||||
? "mit niemandem geteilt."
|
||||
: `mit ${shared.length} Person(en) geteilt` +
|
||||
(openInvites.length
|
||||
? `, ${openInvites.length} Einladung(en) offen.`
|
||||
: "."))
|
||||
),
|
||||
|
||||
inviteForm(),
|
||||
|
||||
list.may_share_public ? publicSection() : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h2", {}, "Zugriff"),
|
||||
el("ul.share-list", {}, members.map(memberRow))
|
||||
),
|
||||
|
||||
invites.length
|
||||
? el("section.card", {},
|
||||
el("h2", {}, "Einladungen"),
|
||||
el("ul.share-list", {}, invites.map(inviteRow)))
|
||||
: null,
|
||||
|
||||
shared.length || openInvites.length
|
||||
? el("section.card", {},
|
||||
el("h2", {}, "Freigabe aufheben"),
|
||||
el("p.lead", {},
|
||||
"Entzieht allen Personen den Zugriff und widerruft offene " +
|
||||
"Einladungen. Die Liste selbst bleibt bestehen."),
|
||||
el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Freigabe von „${list.name}“ vollständig aufheben?\n\n` +
|
||||
`${shared.length} Zugriff(e) werden entzogen, ` +
|
||||
`${openInvites.length} Einladung(en) widerrufen.`
|
||||
)) return;
|
||||
guarded(() => post(`/api/lists/${listId}/unshare`));
|
||||
},
|
||||
}, "Freigabe vollständig aufheben"))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empfängerseite: Einladung annehmen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function acceptInviteView(root, { token, onAccepted, toLists }) {
|
||||
let preview;
|
||||
try {
|
||||
preview = await get(`/api/invites/${encodeURIComponent(token)}`);
|
||||
} catch (err) {
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einladung"),
|
||||
el("p.error", {}, err.message),
|
||||
el("button.primary", { type: "button", onclick: toLists }, "Zu meinen Listen")
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const button = el("button.primary", { type: "button" }, "Einladung annehmen");
|
||||
const error = el("p.error", { hidden: true });
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
error.hidden = true;
|
||||
try {
|
||||
await post(`/api/invites/${encodeURIComponent(token)}/accept`);
|
||||
onAccepted();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einladung"),
|
||||
el("p.lead", {},
|
||||
preview.invited_by_name
|
||||
? `${preview.invited_by_name} teilt die Liste „${preview.list_name}“ mit dir.`
|
||||
: `Die Liste „${preview.list_name}“ wurde mit dir geteilt.`),
|
||||
el("p", {}, `Berechtigung: ${ROLE_LABEL[preview.role]}`),
|
||||
preview.matches_current_user
|
||||
? null
|
||||
: el("p.lead.warn", {},
|
||||
`Diese Einladung ist an ${preview.email} gerichtet, du bist aber mit ` +
|
||||
"einem anderen Konto angemeldet. Melde dich mit der eingeladenen " +
|
||||
"Adresse an oder bitte um eine neue Einladung."),
|
||||
error,
|
||||
preview.matches_current_user ? button : null,
|
||||
el("p.switch", {},
|
||||
el("button.linklike", { type: "button", onclick: toLists }, "Später"))
|
||||
)
|
||||
);
|
||||
}
|
||||
81
web/html/js/views/welcome.js
Normal file
81
web/html/js/views/welcome.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Willkommensstrecke: Passwort setzen über den Link aus der
|
||||
// Einladungsnachricht. Läuft ohne Anmeldung.
|
||||
"use strict";
|
||||
|
||||
import { get, post } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
|
||||
export async function welcomeView(root, { token, toLogin }) {
|
||||
let preview;
|
||||
try {
|
||||
preview = await get(`/api/auth/welcome/${encodeURIComponent(token)}`);
|
||||
} catch (err) {
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einladung"),
|
||||
el("p.error", {}, err.message),
|
||||
el("button.primary", { type: "button", onclick: toLogin }, "Zur Anmeldung")));
|
||||
return;
|
||||
}
|
||||
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
maxLength: 80,
|
||||
value: preview.display_name || "",
|
||||
placeholder: "wird anderen Mitgliedern angezeigt",
|
||||
});
|
||||
const passField = el("input", {
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") submit(); },
|
||||
});
|
||||
const repeatField = el("input", {
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") submit(); },
|
||||
});
|
||||
const error = el("p.error", { hidden: true });
|
||||
const notice = el("p.notice", { hidden: true });
|
||||
const button = el("button.primary", { type: "button" }, "Zugang einrichten");
|
||||
|
||||
async function submit() {
|
||||
error.hidden = true;
|
||||
if (passField.value !== repeatField.value) {
|
||||
error.textContent = "Die beiden Eingaben stimmen nicht überein.";
|
||||
error.hidden = false;
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await post("/api/auth/welcome/complete", {
|
||||
token,
|
||||
password: passField.value,
|
||||
display_name: nameField.value.trim() || null,
|
||||
});
|
||||
notice.textContent = result.detail;
|
||||
notice.hidden = false;
|
||||
setTimeout(toLogin, 1500);
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener("click", submit);
|
||||
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Willkommen"),
|
||||
el("p.lead", {},
|
||||
`Für ${preview.email} wurde ein Zugang eingerichtet. `,
|
||||
"Leg jetzt dein Passwort fest – danach kannst du dich anmelden."),
|
||||
el("label", {}, "Anzeigename ", el("span.hint", {}, "(freiwillig)")), nameField,
|
||||
el("label", {}, "Passwort ", el("span.hint", {}, "(mindestens 12 Zeichen)")), passField,
|
||||
el("label", {}, "Wiederholen"), repeatField,
|
||||
error,
|
||||
notice,
|
||||
button));
|
||||
|
||||
passField.focus();
|
||||
}
|
||||
Reference in New Issue
Block a user