Files
einkaufsapp/web/html/js/views/lists.js

291 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ü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;
// Ist das Archiv aufgeklappt?
let archiveOpen = false;
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
function formatDate(iso) {
if (!iso) return "";
return new Date(iso).toLocaleDateString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
});
}
export async function listsView(root, { openList, onLogout, settings }) {
let banner = null;
let archived = [];
async function reload() {
// Zwei Abfragen statt einer mit Filterung im Browser: Das Archiv
// wird selten geöffnet und kann mit der Zeit groß werden.
const [active, old] = await Promise.all([
get("/api/lists?archived=false"),
get("/api/lists?archived=true"),
]);
archived = old;
set({ lists: active });
}
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", {},
// Archivieren wirkt nur für die eigene Übersicht - deshalb
// steht es allen Mitgliedern offen, nicht nur dem Eigentümer.
el("p.sub", {},
entry.archived
? `Seit ${formatDate(entry.archived_at)} in deinem Archiv.`
: "Archivieren nimmt die Liste aus deiner Übersicht " +
"für die anderen Mitglieder bleibt sie sichtbar."),
entry.archived && entry.delete_due_at
? el("p.lead.warn", {},
`Wird am ${formatDate(entry.delete_due_at)} automatisch ` +
"gelöscht, wenn du sie nicht zurückholst.")
: null,
el("div.menu-actions", {},
el("button", {
type: "button",
onclick: () => guarded(() =>
post(`/api/lists/${entry.id}/${entry.archived ? "un" : ""}archive`)),
}, entry.archived ? "Aus dem Archiv holen" : "Archivieren"),
isOwner
? el("button", {
type: "button",
onclick: () => {
openMenuId = null;
renamingId = entry.id;
render();
},
}, "Umbenennen")
: null),
isOwner
? el("button.danger.wide", {
type: "button",
onclick: () => remove(entry),
}, "Löschen")
: el("button.danger.wide", {
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", {},
archived.length
? "Alle Listen liegen im Archiv."
: "Noch keine Liste vorhanden."),
el("label", { htmlFor: "new-list" }, "Neue Liste"),
createRow()),
archived.length
? el("section.card.archive", {},
el("button.archive-head", {
type: "button",
"aria-expanded": archiveOpen ? "true" : "false",
onclick: () => { archiveOpen = !archiveOpen; render(); },
},
el("span", {}, `Archiv (${archived.length})`),
el("span.chev", {}, archiveOpen ? "▾" : "▸")),
archiveOpen
? el("div", {},
el("p.lead", {},
"Listen, die du weggeräumt hast oder die du länger " +
"nicht geöffnet hast. Sie sind nicht gelöscht ein " +
"Klick holt sie zurück."),
el("ul.lists", {}, archived.map(row)))
: null)
: null
);
}
await reload();
render();
return render;
}