Erste Produktivversion
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user