Seitenleiste: Eintraege ausblenden, als Vorgabe und je Konto

In den Einstellungen legt der Administrator fest, was es im Haushalt
ueberhaupt gibt; in "Mein Konto" blendet jeder zusaetzlich aus, was er
selbst nicht braucht. Angehakt heisst sichtbar - auch wenn der Server das
Gegenteil speichert. Was der Administrator ausgeblendet hat, steht beim
Konto abgehakt und gesperrt da: Die eigene Auswahl addiert nur.

Uebersicht und Einstellungen fehlen in der Liste mit Absicht - dort landen
alle Anmeldewege bzw. es ist der einzige Weg zurueck. Wer beides
verstecken koennte, sperrte sich aus.

Ausgeblendet wird ueber einen Kontext statt per CSS-Klasse: Unter 768px
blendet die CSS die Listen-Kinder wieder ein, eine Klasse bisse sich
damit. Faellt dabei die ganze Listen-Gruppe weg, verschwindet auch ihr
Aufklapp-Knopf, und die Ueberschrift "Stammdaten" steht nur noch, solange
etwas darunter ist.

Die Leiste zieht sofort nach, ueber dasselbe Ereignis-Muster wie bei den
Dashboards - kein Neuladen noetig.

Versteckt wird nur der Eintrag, nicht die Seite: /shopping bleibt per
Lesezeichen und ueber die Dashboard-Karten erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-28 20:31:50 +01:00
co-authored by Claude Opus 5
parent be267e70a5
commit 757d06100a
7 changed files with 256 additions and 10 deletions
+46 -10
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { api } from "./api";
import { useAuth } from "./auth";
import { gruppenPfade } from "./nav";
import Icon from "./components/Icon";
import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark";
@@ -36,6 +37,11 @@ import Konto from "./pages/Konto";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
// Ausgeblendete Eintraege stehen hier drin. Ueber einen Kontext und nicht als
// Eigenschaft an jedem NavItem: So bleiben die zwanzig Zeilen der Leiste
// unveraendert lesbar.
const VersteckteNav = createContext(new Set());
// Die vier Bestandslisten stehen unter einer zuklappbaren Gruppe. Sie sind der
// laengste Block der Leiste und wird im Alltag selten gebraucht – wer einlagert,
// nutzt die Knoepfe darueber.
@@ -48,6 +54,9 @@ const LISTEN = [
const LISTEN_OFFEN_KEY = "nav:listen-offen";
function NavItem({ to, icon, label, end }) {
// Gar nicht erst rendern statt per CSS verbergen: Unter 768px blendet die
// CSS die Listen-Kinder wieder ein, eine Klasse bisse sich damit.
if (useContext(VersteckteNav).has(to)) return null;
return (
<NavLink to={to} end={end} className={navClass}>
<Icon name={icon} size={17} />
@@ -64,6 +73,9 @@ function Sidebar() {
// Der Rezept-Eintrag erscheint nur, wenn Mealie eingerichtet ist. Ein Fehler
// hier darf die Leiste nicht stoeren, deshalb still abfangen.
const [mealie, setMealie] = useState(false);
// Vorgabe des Administrators plus eigene Auswahl, vom Server schon
// zusammengerechnet. Ein Fehler darf die Leiste nicht leeren.
const [versteckt, setVersteckt] = useState(() => new Set());
// Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die
// Spalteneinstellungen der Tabellen.
const [listenOffen, setListenOffen] = useState(
@@ -71,7 +83,11 @@ function Sidebar() {
);
// Steht man auf einer dieser Seiten, ist die Gruppe offen – ohne den
// gemerkten Zustand zu ueberschreiben. Sonst saehe man nicht, wo man ist.
const aufEinerListe = LISTEN.some((e) => pathname.startsWith(e.to));
const sichtbareListen = LISTEN.filter((e) => !versteckt.has(e.to));
const aufEinerListe = sichtbareListen.some((e) => pathname.startsWith(e.to));
// Eine Gruppe ohne Kinder waere ein Knopf, der nichts aufklappt; eine
// Ueberschrift ohne Eintraege darunter genauso sinnlos.
const zeigtGruppe = (gruppe) => gruppenPfade(gruppe).some((t) => !versteckt.has(t));
function klappeListen() {
setListenOffen((alt) => {
@@ -93,6 +109,20 @@ function Sidebar() {
return () => window.removeEventListener("dashboards-geaendert", laden);
}, []);
// Dasselbe Spiel fuer die ausgeblendeten Eintraege: Einstellungen und
// Kontoseite loesen beim Speichern ein Ereignis aus, statt die Seite neu zu
// laden.
useEffect(() => {
function laden() {
api.navigation()
.then((a) => setVersteckt(new Set(a.versteckt || [])))
.catch(() => {});
}
laden();
window.addEventListener("navigation-geaendert", laden);
return () => window.removeEventListener("navigation-geaendert", laden);
}, []);
useEffect(() => {
api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {});
}, []);
@@ -107,6 +137,7 @@ function Sidebar() {
<div className="sidebar-brand">
<BrandMark />
</div>
<VersteckteNav.Provider value={versteckt}>
<nav className="sidebar-nav">
{/* Ohne `end`: Der Eintrag soll auch bei /home/edit markiert bleiben.
Bei "/" war `end` noetig, weil sonst JEDER Pfad als Treffer galt. */}
@@ -124,13 +155,15 @@ function Sidebar() {
{/* Auf schmalen Bildschirmen wird die Leiste waagerecht; dort gibt es
keine Abschnitte (styles.css) und die Eintraege stehen flach
nebeneinander – zuklappen loest dort kein Platzproblem. */}
<button type="button" className="nav-link nav-group" onClick={klappeListen}
aria-expanded={listenOffen || aufEinerListe}>
<Icon name="chevron" size={17}
className={listenOffen || aufEinerListe ? "" : "rot-90"} />
<span className="label">Listen</span>
</button>
{LISTEN.map((e) => (
{sichtbareListen.length > 0 && (
<button type="button" className="nav-link nav-group" onClick={klappeListen}
aria-expanded={listenOffen || aufEinerListe}>
<Icon name="chevron" size={17}
className={listenOffen || aufEinerListe ? "" : "rot-90"} />
<span className="label">Listen</span>
</button>
)}
{sichtbareListen.map((e) => (
<NavLink key={e.to} to={e.to}
className={({ isActive }) => [
"nav-link nav-child",
@@ -152,7 +185,7 @@ function Sidebar() {
<>
{/* Stammdaten strukturieren den Katalog und werden nur von Admins
gepflegt – deshalb alle zusammen hier, wie in der iOS-App. */}
<div className="nav-section">Stammdaten</div>
{zeigtGruppe("stammdaten") && <div className="nav-section">Stammdaten</div>}
<NavItem to="/categories" icon="package" label="Kategorien" />
<NavItem to="/groups" icon="tag" label="Gruppen" />
<NavItem to="/locations" icon="location" label="Lagerorte" />
@@ -160,6 +193,8 @@ function Sidebar() {
<NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" />
<NavItem to="/masse" icon="split" label="Maße" />
{/* "System" steht immer, weil die Einstellungen darunter nicht
ausblendbar sind – sonst käme man nicht mehr zurück. */}
<div className="nav-section">System</div>
<NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/transfer" icon="history" label="Import / Export" />
@@ -167,6 +202,7 @@ function Sidebar() {
</>
)}
</nav>
</VersteckteNav.Provider>
<div className="sidebar-foot">
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
<NavLink to="/konto" className="sidebar-user" title="Mein Konto">
+8
View File
@@ -437,6 +437,14 @@ export const api = {
request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }),
// Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/<id>);
// nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist.
// Seitenleiste: Vorgabe des Admins, eigene Auswahl und die fertige
// Vereinigung – alles in einem Zug, das Zusammenrechnen macht der Server.
navigation: () => request("/navigation"),
setzeEigeneNavigation: (versteckt) =>
request("/navigation", { method: "PUT", body: { versteckt } }),
setzeNavigationsVorgabe: (versteckt) =>
request("/navigation/vorgabe", { method: "PUT", body: { versteckt } }),
etikettenCsv: (id, name) =>
downloadFile(
`/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`,
+48
View File
@@ -0,0 +1,48 @@
import { NAV_AUSBLENDBAR } from "../nav";
import { useAuth } from "../auth";
/**
* Häkchenliste für die Seitenleiste – an zwei Stellen benutzt: in den
* Einstellungen für die Vorgabe des Administrators, auf der Kontoseite für die
* eigene Auswahl.
*
* Angehakt heisst **sichtbar**. Das ist herum, wie man es erwartet, auch wenn
* der Server das Gegenteil speichert (die Liste der versteckten Pfade).
*
* `gesperrt` sind Einträge, die der Administrator schon ausgeblendet hat: Sie
* stehen abgehakt und unveränderlich da, denn die eigene Auswahl addiert nur –
* sie nimmt nichts weg.
*/
export default function NavigationAuswahl({ versteckt, gesperrt = [], onChange }) {
const { isAdmin } = useAuth();
const aus = new Set(versteckt);
const fest = new Set(gesperrt);
// Stammdaten und System sieht ohnehin nur ein Administrator.
const eintraege = NAV_AUSBLENDBAR.filter(
(e) => isAdmin || !["stammdaten", "system"].includes(e.gruppe),
);
function umschalten(to, sichtbar) {
const neu = new Set(aus);
if (sichtbar) neu.delete(to);
else neu.add(to);
onChange([...neu]);
}
return (
<div className="nav-auswahl">
{eintraege.map((e) => {
const blockiert = fest.has(e.to);
return (
<label key={e.to} className="check-inline"
title={blockiert ? "Vom Administrator ausgeblendet" : undefined}>
<input type="checkbox" disabled={blockiert}
checked={!blockiert && !aus.has(e.to)}
onChange={(ev) => umschalten(e.to, ev.target.checked)} />
<span className={blockiert ? "muted" : ""}>{e.label}</span>
</label>
);
})}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Die Einträge der Seitenleiste, die sich ausblenden lassen.
*
* Als Schlüssel dient der Routenpfad – der ist ohnehin eindeutig, eine zweite
* Benennung wäre nur eine weitere Stelle, die auseinanderlaufen kann.
*
* **Übersicht und Einstellungen fehlen hier mit Absicht:** Auf „/home" landen
* alle Anmeldewege und das Auffangmuster unbekannter Adressen, und über
* „/settings" schaltet man Ausgeblendetes wieder ein. Wer beides verstecken
* könnte, sperrte sich aus. „Mein Konto" steht in der Fusszeile und ist gar
* kein Eintrag der Leiste.
*
* Versteckt wird nur der Eintrag, nicht die Seite: Lesezeichen,
* Dashboard-Karten und Lagerort-QR verweisen weiter auf diese Routen.
*/
export const NAV_AUSBLENDBAR = [
{ to: "/checkin", label: "Einlagern" },
{ to: "/checkout", label: "Auslagern" },
{ to: "/lebensmittel", label: "Lebensmittel", gruppe: "listen" },
{ to: "/gegenstaende", label: "Gegenstände", gruppe: "listen" },
{ to: "/items", label: "Einzelstücke", gruppe: "listen" },
{ to: "/chargen", label: "Chargen", gruppe: "listen" },
{ to: "/rezepte", label: "Rezepte" },
{ to: "/shopping", label: "Einkaufsliste" },
{ to: "/mindestbestaende", label: "Mindestbestände" },
{ to: "/verliehen", label: "Verliehen" },
{ to: "/history", label: "Verlauf" },
{ to: "/categories", label: "Kategorien", gruppe: "stammdaten" },
{ to: "/groups", label: "Gruppen", gruppe: "stammdaten" },
{ to: "/locations", label: "Lagerorte", gruppe: "stammdaten" },
{ to: "/shops", label: "Shops", gruppe: "stammdaten" },
{ to: "/units", label: "Einheiten", gruppe: "stammdaten" },
{ to: "/package-types", label: "Gebinde", gruppe: "stammdaten" },
{ to: "/masse", label: "Maße", gruppe: "stammdaten" },
{ to: "/users", label: "Benutzer", gruppe: "system" },
{ to: "/transfer", label: "Import / Export", gruppe: "system" },
];
/** Pfade einer Gruppe – für die Abschnittsüberschriften der Leiste. */
export function gruppenPfade(gruppe) {
return NAV_AUSBLENDBAR.filter((e) => e.gruppe === gruppe).map((e) => e.to);
}
+55
View File
@@ -4,6 +4,7 @@ import { useAuth } from "../auth";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon";
import NavigationAuswahl from "../components/NavigationAuswahl";
// Kennungen aus dem Rueckweg des Anmeldedienstes (`/konto#sso_error=…`).
const SSO_FEHLERTEXTE = {
@@ -209,6 +210,60 @@ export default function Konto() {
</>
)}
</div>
<SeitenleisteKarte />
</div>
);
}
/**
* Die eigene Auswahl: was ICH in der Leiste nicht brauche.
*
* Was der Administrator ausgeblendet hat, steht gesperrt da – die eigene
* Auswahl addiert nur, sie nimmt nichts weg.
*/
function SeitenleisteKarte() {
const toast = useToast();
const [daten, setDaten] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
api.navigation().then(setDaten).catch((e) => setError(e.message));
}, []);
async function speichern(neu) {
setDaten((alt) => ({ ...alt, eigen: neu }));
setBusy(true);
setError(null);
try {
await api.setzeEigeneNavigation(neu);
window.dispatchEvent(new Event("navigation-geaendert"));
toast("Seitenleiste gespeichert.");
} catch (err) { setError(err.message); } finally { setBusy(false); }
}
return (
<div className="card">
<div className="card-head"><Icon name="dashboard" /><h2>Meine Seitenleiste</h2></div>
<p className="muted small">
Abgewählte Einträge verschwinden nur bei dir. Die Seiten bleiben
erreichbar – Lesezeichen und Links funktionieren weiter.
</p>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{daten == null
? <p className="muted small">Wird geladen…</p>
: <>
<fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0 }}>
<NavigationAuswahl versteckt={daten.eigen || []}
gesperrt={daten.vorgabe || []} onChange={speichern} />
</fieldset>
{(daten.vorgabe || []).length > 0 && (
<p className="muted small">
Ausgegraute Einträge hat der Administrator für alle ausgeblendet.
</p>
)}
</>}
</div>
);
}
+49
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import NavigationAuswahl from "../components/NavigationAuswahl";
import { useToast } from "../toast";
import BrandingUpload from "../components/BrandingUpload";
import DangerAction from "../components/DangerAction";
@@ -192,6 +193,7 @@ export default function Settings() {
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
</form>
<SeitenleisteCard />
<MealieCard />
<ApiTokensCard />
<DangerZoneCard />
@@ -199,6 +201,53 @@ export default function Settings() {
);
}
/**
* Welche Einträge der Seitenleiste es im Haushalt überhaupt gibt.
*
* Was hier abgewählt ist, sieht niemand – auch kein Administrator. Jeder kann
* in seinem Konto zusätzlich eigene Einträge ausblenden, aber nichts davon
* wieder einblenden.
*/
function SeitenleisteCard() {
const toast = useToast();
const [versteckt, setVersteckt] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
api.navigation().then((a) => setVersteckt(a.vorgabe || [])).catch((e) => setError(e.message));
}, []);
async function speichern(neu) {
setVersteckt(neu);
setBusy(true);
setError(null);
try {
await api.setzeNavigationsVorgabe(neu);
// Die Leiste horcht darauf, statt dass man neu laden muss.
window.dispatchEvent(new Event("navigation-geaendert"));
toast("Seitenleiste gespeichert.");
} catch (err) { setError(err.message); } finally { setBusy(false); }
}
return (
<div className="card">
<div className="card-head"><Icon name="dashboard" /><h2>Seitenleiste</h2></div>
<p className="muted small">
Was hier abgewählt ist, verschwindet für alle aus der Leiste. Die Seiten
selbst bleiben erreichbar – Lesezeichen und Links funktionieren weiter.
Übersicht und Einstellungen lassen sich nicht ausblenden.
</p>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{versteckt == null
? <p className="muted small">Wird geladen…</p>
: <fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0 }}>
<NavigationAuswahl versteckt={versteckt} onChange={speichern} />
</fieldset>}
</div>
);
}
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
/**
* Verbindung zu einer selbst gehosteten Mealie-Instanz.
+8
View File
@@ -1264,3 +1264,11 @@ tr.row-active { background: var(--surface-2); }
/* "Foto machen"/"Galerie" sind <label> und erben sonst das margin-bottom des
globalen label-Stils – dann saessen sie hoeher als der <button> "Entfernen". */
.foto-knoepfe .btn { cursor: pointer; margin: 0; }
/* Die Haekchenliste fuer die Seitenleiste: mehrspaltig, damit die zwanzig
Eintraege nicht als lange Saeule dastehen. */
.nav-auswahl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
gap: var(--sp-2) var(--sp-4);
}