Anpassung Menü

This commit is contained in:
2026-08-09 18:44:21 +02:00
parent b80e2d5a6c
commit 8ffbda875e
13 changed files with 365 additions and 58 deletions

View File

@@ -21,6 +21,7 @@ import { adminView } from "./views/admin.js";
import { settingsView } from "./views/settings.js";
import { welcomeView } from "./views/welcome.js";
import { manageView } from "./views/manage.js";
import { closeMenu } from "./appbar.js";
import * as db from "./db.js";
import { installTriggers, stopWatching } from "./sync.js";
import { publicView } from "./views/public.js";
@@ -60,6 +61,8 @@ function currentRoute() {
/** Beim Verlassen der Detailansicht den Ereigniskanal schließen -
* sonst bleibt pro besuchter Liste eine offene Verbindung stehen. */
function navigate(hash) {
// Sonst bliebe ein offenes Menü über den Wechsel hinweg stehen.
closeMenu();
stopWatching();
if (location.hash === hash) {
route();

128
web/html/js/appbar.js Normal file
View File

@@ -0,0 +1,128 @@
// Gemeinsame Kopfleiste für alle Ansichten.
//
// Vorher hatte jede Ansicht ihre eigene Zeile aus Textlinks. Bei sechs
// Einträgen unterschiedlicher Länge - "Drucken", "Märkte & Gruppen",
// "Teilen (2)" - brach das auf schmalen Geräten unregelmäßig um, und die
// Grundlinien lagen versetzt.
//
// Jetzt: links der Weg zurück, rechts ein Menü, dessen Einträge
// untereinander stehen. Eine einzelne Aktion wird direkt angezeigt -
// ein Menü mit einem Eintrag wäre ein Klick zu viel.
"use strict";
import { el } from "./dom.js";
let openPanel = null;
/** Schließt ein offenes Menü. Wird von aussen aufgerufen, wenn die
* Ansicht wechselt - sonst bliebe das Menü über den Wechsel hinweg
* stehen. */
export function closeMenu() {
if (!openPanel) return;
openPanel.panel.hidden = true;
openPanel.button.setAttribute("aria-expanded", "false");
document.removeEventListener("click", openPanel.onOutside, true);
document.removeEventListener("keydown", openPanel.onKey);
openPanel = null;
}
/**
* @param {object} options
* @param {{label: string, onClick?: Function}} [options.back]
* @param {string} [options.title] Kontext, etwa der Listenname
* @param {Array<{label: string, onClick?: Function, href?: string,
* danger?: boolean, hint?: string}>} [options.actions]
*/
export function appBar({ back, title, actions = [] } = {}) {
closeMenu();
const usable = actions.filter(Boolean);
const left = back
? el("button.bar-back", { type: "button", onclick: back.onClick },
"\u2039 " + back.label)
: el("span.bar-back", {});
const middle = title ? el("span.bar-title", { title }, title) : el("span", {});
function entry(action) {
const content = [
el("span.entry-label", {}, action.label),
action.hint ? el("span.entry-hint", {}, action.hint) : null,
];
// Ein Verweis bleibt ein Verweis: So funktionieren "In neuem Tab
// öffnen" und das Kopieren der Adresse wie erwartet.
const node = action.href
? el("a", {
href: action.href,
target: action.target || "_blank",
rel: "noopener",
className: action.danger ? "danger" : "",
onclick: () => closeMenu(),
}, content)
: el("button", {
type: "button",
className: action.danger ? "danger" : "",
onclick: () => { closeMenu(); action.onClick?.(); },
}, content);
return el("li", {}, node);
}
let right;
if (usable.length === 0) {
right = el("span", {});
} else if (usable.length === 1) {
const only = usable[0];
right = only.href
? el("a.bar-single", {
href: only.href, target: only.target || "_blank", rel: "noopener",
}, only.label)
: el("button.bar-single", { type: "button", onclick: only.onClick },
only.label);
} else {
const panel = el("ul.menu-panel", { hidden: true }, usable.map(entry));
const button = el("button.burger", {
type: "button",
"aria-haspopup": "true",
"aria-expanded": "false",
"aria-label": "Menü",
title: "Menü",
},
el("span.burger-lines", { "aria-hidden": "true" }),
el("span.burger-text", {}, "Menü"));
button.addEventListener("click", (ev) => {
ev.stopPropagation();
if (openPanel && openPanel.panel === panel) {
closeMenu();
return;
}
closeMenu();
panel.hidden = false;
button.setAttribute("aria-expanded", "true");
const onOutside = (event) => {
if (!panel.contains(event.target) && event.target !== button) {
closeMenu();
}
};
const onKey = (event) => {
if (event.key === "Escape") {
closeMenu();
button.focus();
}
};
// In der Erfassungsphase, damit auch Klicks auf Elemente greifen,
// die ihrerseits die Weiterleitung stoppen.
document.addEventListener("click", onOutside, true);
document.addEventListener("keydown", onKey);
openPanel = { panel, button, onOutside, onKey };
});
right = el("div.bar-menu", {}, button, panel);
}
return el("header.app-bar", {}, left, middle, right);
}

View File

@@ -4,6 +4,7 @@
import { del, get, post, put } from "../api.js";
import { el, mount } from "../dom.js";
import { state } from "../store.js";
import { appBar } from "../appbar.js";
let openId = null;
@@ -361,8 +362,10 @@ export async function adminView(root, { back }) {
function render() {
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back }, " Zurück")),
appBar({
back: { label: "Einstellungen", onClick: back },
title: "Benutzerverwaltung",
}),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,

View File

@@ -6,6 +6,7 @@ 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";
import { appBar } from "../appbar.js";
let editingId = null;
let query = "";
@@ -261,9 +262,7 @@ export async function articlesView(root, { listId, back }) {
});
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back },
" Zurück zur Liste")),
appBar({ back: { label: "Zurück", onClick: back }, title: "Artikel" }),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,

View File

@@ -19,6 +19,7 @@ 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";
import { appBar } from "../appbar.js";
let openMenuId = null;
@@ -639,26 +640,30 @@ export async function listDetailView(
(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", {},
appBar({
back: { label: "Listen", onClick: back },
title: view.list_name,
actions: [
{ label: "Märkte & Gruppen", onClick: () => manage(listId),
hint: "Reihenfolge im Laden" },
{ label: "Artikel", onClick: () => articles(listId),
hint: "Stammdaten, Strichcodes" },
{ label: "Preise", onClick: () => prices(listId),
hint: "Vergleich zwischen Märkten" },
meta && meta.may_share_public
? {
label: meta.member_count > 1
? `Teilen (${meta.member_count})` : "Teilen",
onClick: () => share(listId),
hint: "Personen und öffentliche Links",
}
: null,
// 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)),
{ label: "Drucken", href: `/api/lists/${listId}/print`,
hint: "A4, nach Märkten gegliedert" },
],
}),
statusLine(),
banner ? el("p.error", {}, banner) : null,

View File

@@ -5,6 +5,7 @@ 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";
import { appBar } from "../appbar.js";
// Welche Zeile hat gerade ihr Menü offen
let openMenuId = null;
@@ -244,11 +245,14 @@ export async function listsView(root, { openList, onLogout, settings }) {
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"))),
appBar({
title: state.user.display_name || state.user.email,
actions: [
{ label: "Einstellungen", onClick: settings,
hint: "Benachrichtigungen, Passwort" },
{ label: "Abmelden", onClick: onLogout, danger: true },
],
}),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,

View File

@@ -9,6 +9,7 @@ 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";
import { appBar } from "../appbar.js";
const STEP = 10;
@@ -148,10 +149,7 @@ export async function manageView(root, { listId, back }) {
function render() {
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back },
" Zurück zur Liste"),
listName ? el("span.who", {}, listName) : null),
appBar({ back: { label: "Zurück", onClick: back }, title: listName }),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,

View File

@@ -8,6 +8,7 @@
import { get } from "../api.js";
import { el, euro, formatQuantity, mount } from "../dom.js";
import { appBar } from "../appbar.js";
let expandedId = null;
@@ -127,9 +128,7 @@ export async function pricesView(root, { listId, back }) {
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")),
appBar({ back: { label: "Zurück", onClick: back }, title: "Preise" }),
banner ? el("p.error", {}, banner) : null,

View File

@@ -6,6 +6,7 @@ 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";
import { appBar } from "../appbar.js";
function formatDate(iso) {
return new Date(iso).toLocaleDateString("de-DE", {
@@ -205,22 +206,24 @@ export async function settingsView(root, { back, admin }) {
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)),
appBar({
back: { label: "Listen", onClick: back },
title: "Einstellungen",
actions: state.user.is_admin
? [{ label: "Benutzerverwaltung", onClick: admin,
hint: "Konten, Einladungen, Bereinigung" }]
: [],
}),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
// Die Benutzerverwaltung steht im Menü der Kopfleiste - nur für
// Administratoren, und der Endpunkt prüft zusätzlich.
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),
el("h1", {}, state.user.display_name || state.user.email),
el("p.lead", {},
state.user.email,
state.user.is_admin ? " · Administrator" : "")),
pushSection(),
profileSection(),
passwordSection()

View File

@@ -4,6 +4,7 @@
import { del, get, post, put } from "../api.js";
import { clear, el, mount } from "../dom.js";
import { appBar } from "../appbar.js";
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
@@ -352,9 +353,10 @@ export async function shareView(root, { listId, back }) {
const openInvites = invites.filter((i) => i.status === "pending");
mount(root,
el("header.bar", {},
el("button.linklike", { type: "button", onclick: back }, " Zurück zur Liste")
),
appBar({
back: { label: "Zurück", onClick: back },
title: list ? list.name : "Teilen",
}),
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
el("section.card", {},