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:
co-authored by
Claude Opus 5
parent
be267e70a5
commit
757d06100a
+46
-10
@@ -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">
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user