Files
einkaufsapp/web/html/js/views/lists.js
2026-08-08 20:31:58 +02:00

233 lines
6.9 KiB
JavaScript

// Ü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;
}