From 8ffbda875e6834332f19e141791cf0a29e21dcc8 Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Sun, 9 Aug 2026 18:44:21 +0200 Subject: [PATCH] =?UTF-8?q?Anpassung=20Men=C3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 20 ++++ web/html/app.css | 156 +++++++++++++++++++++++++++++-- web/html/js/app.js | 3 + web/html/js/appbar.js | 128 +++++++++++++++++++++++++ web/html/js/views/admin.js | 7 +- web/html/js/views/articles.js | 5 +- web/html/js/views/list-detail.js | 41 ++++---- web/html/js/views/lists.js | 14 ++- web/html/js/views/manage.js | 6 +- web/html/js/views/prices.js | 5 +- web/html/js/views/settings.js | 27 +++--- web/html/js/views/share.js | 8 +- web/html/sw.js | 3 +- 13 files changed, 365 insertions(+), 58 deletions(-) create mode 100644 web/html/js/appbar.js diff --git a/README.md b/README.md index 129c2dd..a95f2f2 100644 --- a/README.md +++ b/README.md @@ -1196,6 +1196,26 @@ aber weiterhin benutzte Liste verschwinden. Gelöscht wird per Soft Delete: Offline gebliebene Geräte erfahren noch davon, endgültig entfernt der Aufräumlauf nach `CLEANUP_DELETED_DAYS`. +## Kopfleiste + +Alle Ansichten teilen sich eine Leiste (`web/html/js/appbar.js`): links der Weg +zurück, in der Mitte der Kontext (Listenname), rechts ein Menü mit den +Einträgen **untereinander**. + +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. + +Zwei Feinheiten: + +- **Eine einzelne Aktion erscheint direkt**, ohne Menü. Ein Menü mit einem + Eintrag wäre ein Klick zu viel. +- **Verweise bleiben Verweise.** „Drucken" ist ein ``, damit „In neuem Tab + öffnen" und das Kopieren der Adresse funktionieren. + +Das Menü schließt bei Klick daneben, mit Escape und beim Ansichtswechsel. Im +Ausdruck ist die gesamte Leiste ausgeblendet. + ## Einträge bearbeiten Im „⋯"-Menü eines Eintrags lässt sich alles ändern: **Artikel**, Anzahl, diff --git a/web/html/app.css b/web/html/app.css index b4ece33..32f4afc 100644 --- a/web/html/app.css +++ b/web/html/app.css @@ -129,15 +129,156 @@ button.primary:disabled { opacity: .55; cursor: progress; } .switch { margin: 1rem 0 0; text-align: center; font-size: .92rem; } a { color: var(--accent); } -.bar { +/* =================================================================== + Kopfleiste mit Menü + =================================================================== */ + +.app-bar { display: flex; - justify-content: space-between; - align-items: baseline; - gap: 1rem; + align-items: center; + gap: .75rem; margin-bottom: 1rem; - font-size: .92rem; + min-height: 2.6rem; } +/* Drei Bereiche mit festem Verhalten: links zurück, Mitte der Titel + (darf gekürzt werden), rechts das Menü. Vorher standen alle Einträge + nebeneinander und brachen unregelmäßig um. */ +.bar-back { + flex: 0 0 auto; + background: none; + border: none; + padding: .4rem 0; + font: inherit; + font-size: .95rem; + color: var(--accent); + cursor: pointer; + white-space: nowrap; +} + +.bar-title { + flex: 1 1 auto; + min-width: 0; + font-size: .95rem; + font-weight: 600; + color: var(--muted); + /* Ein langer Listenname darf die Leiste nicht sprengen. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + +.bar-back + .bar-title { text-align: right; } +.app-bar > span.bar-back + .bar-title { text-align: left; } + +.bar-menu { position: relative; flex: 0 0 auto; } + +button.burger { + display: flex; + align-items: center; + gap: .4rem; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: .45rem .7rem; + font: inherit; + font-size: .9rem; + color: var(--fg); + cursor: pointer; +} + +button.burger[aria-expanded="true"] { + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: var(--accent); +} + +/* Die drei Striche aus einem Element - spart drei weitere Knoten. */ +.burger-lines { + width: 1.05rem; + height: 2px; + background: currentColor; + position: relative; + display: block; +} + +.burger-lines::before, +.burger-lines::after { + content: ""; + position: absolute; + left: 0; + width: 100%; + height: 2px; + background: currentColor; +} + +.burger-lines::before { top: -.34rem; } +.burger-lines::after { top: .34rem; } + +/* Auf sehr schmalen Geräten nur das Symbol. */ +@media (max-width: 24rem) { + .burger-text { display: none; } +} + +.bar-single { + flex: 0 0 auto; + background: none; + border: none; + padding: .4rem 0; + font: inherit; + font-size: .95rem; + color: var(--accent); + cursor: pointer; + white-space: nowrap; + text-decoration: underline; + text-underline-offset: 2px; +} + +ul.menu-panel { + position: absolute; + z-index: 30; + top: calc(100% + .3rem); + right: 0; + min-width: 15rem; + max-width: min(20rem, calc(100vw - 2rem)); + list-style: none; + margin: 0; + padding: .25rem; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, .2); +} + +ul.menu-panel button, +ul.menu-panel a { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + border-radius: calc(var(--radius) - 2px); + /* Große Trefferfläche: Das Menü wird am Telefon mit dem Daumen + bedient. */ + padding: .6rem .7rem; + font: inherit; + color: inherit; + text-decoration: none; + cursor: pointer; +} + +ul.menu-panel button:hover, +ul.menu-panel a:hover, +ul.menu-panel button:focus-visible, +ul.menu-panel a:focus-visible { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +ul.menu-panel .danger { color: var(--error); } + +.entry-label { display: block; font-weight: 600; } +.entry-hint { display: block; font-size: .78rem; color: var(--muted); } + .who { color: var(--muted); overflow-wrap: anywhere; } .lists { list-style: none; margin: 0 0 1rem; padding: 0; } @@ -460,7 +601,7 @@ li.sortable-row.dragging button.grip { color: var(--accent); } body { background: #fff; color: #000; font-size: 11pt; } #app { max-width: none; padding: 0; } - .bar, .add-row, .summary button, button.menu-toggle, + .app-bar, .bar, .add-row, .summary button, button.menu-toggle, .item-menu, .offline-hint, .footnote, label, .row { display: none !important; } .card, .market, .summary { @@ -554,7 +695,8 @@ li.invite.expired .who-block .name { li.invite.accepted .who-block .name { color: var(--muted); } @media print { - .bar-actions, .member-actions, .share-list { display: none !important; } + .app-bar, .bar-actions, .member-actions, .share-list, + ul.menu-panel { display: none !important; } } /* =================================================================== diff --git a/web/html/js/app.js b/web/html/js/app.js index a412907..03ddc19 100644 --- a/web/html/js/app.js +++ b/web/html/js/app.js @@ -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(); diff --git a/web/html/js/appbar.js b/web/html/js/appbar.js new file mode 100644 index 0000000..32c5318 --- /dev/null +++ b/web/html/js/appbar.js @@ -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); +} diff --git a/web/html/js/views/admin.js b/web/html/js/views/admin.js index 0a6fe5d..28867ec 100644 --- a/web/html/js/views/admin.js +++ b/web/html/js/views/admin.js @@ -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, diff --git a/web/html/js/views/articles.js b/web/html/js/views/articles.js index d56c584..4b8b48d 100644 --- a/web/html/js/views/articles.js +++ b/web/html/js/views/articles.js @@ -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, diff --git a/web/html/js/views/list-detail.js b/web/html/js/views/list-detail.js index b3ee5d2..d53f2c9 100644 --- a/web/html/js/views/list-detail.js +++ b/web/html/js/views/list-detail.js @@ -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, diff --git a/web/html/js/views/lists.js b/web/html/js/views/lists.js index 7237304..cdbb2a1 100644 --- a/web/html/js/views/lists.js +++ b/web/html/js/views/lists.js @@ -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, diff --git a/web/html/js/views/manage.js b/web/html/js/views/manage.js index d2a2253..f10746c 100644 --- a/web/html/js/views/manage.js +++ b/web/html/js/views/manage.js @@ -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, diff --git a/web/html/js/views/prices.js b/web/html/js/views/prices.js index 295258e..43f9734 100644 --- a/web/html/js/views/prices.js +++ b/web/html/js/views/prices.js @@ -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, diff --git a/web/html/js/views/settings.js b/web/html/js/views/settings.js index 751abe9..f397ec7 100644 --- a/web/html/js/views/settings.js +++ b/web/html/js/views/settings.js @@ -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() diff --git a/web/html/js/views/share.js b/web/html/js/views/share.js index b2289f5..64fa6b2 100644 --- a/web/html/js/views/share.js +++ b/web/html/js/views/share.js @@ -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", {}, diff --git a/web/html/sw.js b/web/html/sw.js index 8028763..35818ff 100644 --- a/web/html/sw.js +++ b/web/html/sw.js @@ -5,7 +5,7 @@ // Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet, // wann alte Zwischenspeicher verworfen werden. -const VERSION = "v24"; +const VERSION = "v25"; const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`; const SHELL = [ @@ -15,6 +15,7 @@ const SHELL = [ "/js/api.js", "/js/dom.js", "/js/store.js", + "/js/appbar.js", "/js/db.js", "/js/sync.js", "/js/push.js",