291 lines
9.2 KiB
JavaScript
291 lines
9.2 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;
|
||
// 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;
|
||
}
|