diff --git a/web/src/finance.css b/web/src/finance.css
index 2cf7930..059607c 100644
--- a/web/src/finance.css
+++ b/web/src/finance.css
@@ -1194,3 +1194,116 @@ tr.row-dup {
gap: 4px;
color: var(--accent);
}
+
+/* ---------- Einstellungen: Abschnittsleiste + gestapelte Abschnitte ----------
+ Vorher lagen die Karten in einem Spaltenfluss nebeneinander. Man fand nichts
+ wieder, weil die Reihenfolge von der Kartenhöhe abhing statt vom Thema.
+ Jetzt: eine Spalte in fester Reihenfolge, daneben eine Leiste, die zeigt, wo
+ man ist, und mit einem Klick dorthin springt. */
+.settings-layout {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--sp-5);
+}
+.settings-nav {
+ flex: 0 0 200px;
+ position: sticky;
+ top: var(--sp-5);
+ /* Eigener Bildlauf, falls die Liste einmal höher wird als der Schirm –
+ sonst wären die untersten Punkte nicht erreichbar. */
+ max-height: calc(100vh - var(--sp-6) * 2);
+ overflow-y: auto;
+}
+.settings-nav ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.settings-nav a {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-2);
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ color: var(--muted);
+ text-decoration: none;
+ font-weight: 560;
+ border-left: 2px solid transparent;
+ min-width: 0;
+}
+.settings-nav a:hover {
+ background: var(--surface-2);
+ color: var(--text);
+}
+.settings-nav a.active {
+ background: var(--surface-2);
+ color: var(--accent);
+ border-left-color: var(--accent);
+}
+.settings-inhalt {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-4);
+}
+/* Beim Anspringen soll die Überschrift nicht am oberen Rand kleben. */
+.settings-section {
+ scroll-margin-top: var(--sp-5);
+}
+
+@media (max-width: 1000px) {
+ /* Drei Spalten passen nicht mehr: Die Abschnittsleiste klappt über den
+ Inhalt und wird zur waagerecht scrollbaren Reihe. */
+ .settings-layout {
+ flex-direction: column;
+ /* Ohne das erbt die Spalten-Anordnung das flex-start von oben – dort ist
+ es für die klebende Leiste nötig, hier presst es Leiste und Inhalt auf
+ ihre Inhaltsbreite zusammen statt auf die volle. */
+ align-items: stretch;
+ }
+ .settings-nav {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ flex: 0 0 auto;
+ width: 100%;
+ max-height: none;
+ padding: var(--sp-2) 0;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ }
+ .settings-nav ul {
+ flex-direction: row;
+ overflow-x: auto;
+ gap: var(--sp-2);
+ }
+ .settings-nav a {
+ white-space: nowrap;
+ border-left: none;
+ border-bottom: 2px solid transparent;
+ }
+ .settings-nav a.active {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent);
+ }
+ .settings-section {
+ scroll-margin-top: 56px;
+ }
+}
+
+@media (max-width: 760px) {
+ /* Ab hier ist die Hauptnavigation selbst eine klebende Leiste am oberen Rand
+ (z-index 20). Eine zweite klebende Leiste darunter verschwände hinter ihr –
+ also scrollt die Abschnittsleiste hier einfach mit. */
+ .settings-nav {
+ position: static;
+ border-bottom: none;
+ }
+ .settings-section {
+ scroll-margin-top: var(--sp-3);
+ }
+}
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index 7d1271f..14291f9 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -106,7 +106,8 @@
"backupHint": "Das Voll-Backup enthält alle deine Daten und lässt sich unten wieder einspielen.",
"exportBackup": "Voll-Backup (JSON)",
"systemBackup": "Systemweites Voll-Backup (JSON)",
- "systemBackupWarning": "Das systemweite Backup enthält Passwort-Hashes und Zwei-Faktor-Geheimnisse aller Benutzer. Behandle die Datei wie ein Passwort."
+ "systemBackupWarning": "Das systemweite Backup enthält Passwort-Hashes und Zwei-Faktor-Geheimnisse aller Benutzer. Behandle die Datei wie ein Passwort.",
+ "sections": "Abschnitte"
},
"dashboard": {
"title": "Übersicht",
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index fdd4d83..cae6f0a 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -106,7 +106,8 @@
"backupHint": "The full backup contains all of your data and can be restored below.",
"exportBackup": "Full backup (JSON)",
"systemBackup": "System-wide full backup (JSON)",
- "systemBackupWarning": "The system-wide backup contains password hashes and two-factor secrets of every user. Treat the file like a password."
+ "systemBackupWarning": "The system-wide backup contains password hashes and two-factor secrets of every user. Treat the file like a password.",
+ "sections": "Sections"
},
"dashboard": {
"title": "Overview",
diff --git a/web/src/pages/BankRueckkehr.jsx b/web/src/pages/BankRueckkehr.jsx
index baff473..148be0c 100644
--- a/web/src/pages/BankRueckkehr.jsx
+++ b/web/src/pages/BankRueckkehr.jsx
@@ -76,7 +76,7 @@ export default function BankRueckkehr() {
)}
-
+
{t("bankReturn.toSettings")}
diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx
index 743a645..7e919b4 100644
--- a/web/src/pages/Settings.jsx
+++ b/web/src/pages/Settings.jsx
@@ -1,22 +1,32 @@
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "../settings";
import { useAuth } from "../auth";
import { useToast } from "../toast";
import { api } from "../api";
import Icon from "../components/Icon";
-import Kartenspalten from "../components/Kartenspalten";
import PolicyCard from "./settings/PolicyCard";
import Bankanbindung from "./settings/Bankanbindung";
import Postfaecher from "./settings/Postfaecher";
import RestoreCard from "./settings/RestoreCard";
+// Der Anteil des Schirms, ab dem ein Abschnitt als „der, den man gerade liest"
+// gilt. Ein Viertel von oben – nicht die Oberkante, sonst springt die Marke
+// schon, während der vorige Abschnitt noch fast ganz zu sehen ist.
+const LESEGRENZE = 0.25;
+
export default function Settings() {
const { t } = useTranslation();
const s = useSettings();
const { isAdmin } = useAuth();
const toast = useToast();
const [flagged, setFlagged] = useState([]);
+ const [aktiv, setAktiv] = useState(null);
+ // Angeklickter Abschnitt. Solange er gesetzt ist, hat der Klick Vorrang vor
+ // der Bildlaufposition: Ein kurzer Abschnitt am Seitenende bringt die Seite
+ // ans Ende, und die Seitenende-Regel unten würde sonst einen anderen Punkt
+ // markieren als den, den man gerade angeklickt hat.
+ const gewaehlt = useRef(null);
async function ladenFlagged() {
try { setFlagged(await api.flaggedKeywords()); }
@@ -24,6 +34,85 @@ export default function Settings() {
}
useEffect(() => { ladenFlagged(); }, []);
+ // Die Leiste wird aus derselben Liste gebaut wie die Abschnitte. So kann
+ // kein Punkt entstehen, der ins Leere zeigt.
+ const abschnitte = useMemo(() => [
+ { id: "allgemein", label: t("settings.general"), icon: "settings" },
+ flagged.length > 0
+ && { id: "schlagwoerter", label: t("settings.keywordReview"), icon: "alert" },
+ { id: "bankanbindung", label: t("bank.title"), icon: "landmark" },
+ { id: "postfaecher", label: t("mailbox.title"), icon: "mail" },
+ { id: "sicherung", label: t("settings.backup"), icon: "download" },
+ { id: "wiederherstellen", label: t("restore.title"), icon: "upload" },
+ isAdmin && { id: "richtlinien", label: t("policy.title"), icon: "lock" },
+ ].filter(Boolean), [flagged.length, isAdmin, t]);
+
+ // Welcher Abschnitt gerade gelesen wird. Bewusst über die Bildlaufposition
+ // und nicht per IntersectionObserver: Ein kurzer letzter Abschnitt erreicht
+ // die Lesegrenze nie und bliebe in der Leiste sonst dauerhaft unmarkiert.
+ useEffect(() => {
+ function beiScroll() {
+ if (gewaehlt.current !== null) return;
+ const grenze = window.innerHeight * LESEGRENZE;
+ let treffer = abschnitte[0]?.id ?? null;
+ for (const a of abschnitte) {
+ const el = document.getElementById(a.id);
+ if (el && el.getBoundingClientRect().top <= grenze) treffer = a.id;
+ }
+ // Am Seitenende gewinnt immer der letzte Abschnitt.
+ if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
+ treffer = abschnitte[abschnitte.length - 1]?.id ?? treffer;
+ }
+ setAktiv(treffer);
+ }
+ // Eigenes Scrollen des Nutzers hebt die Vorauswahl wieder auf. An diesen
+ // Ereignissen und nicht an „scroll", weil der Sprung selbst scrollt.
+ function beiEigenemScrollen() {
+ gewaehlt.current = null;
+ beiScroll();
+ }
+
+ beiScroll();
+ window.addEventListener("scroll", beiScroll, { passive: true });
+ window.addEventListener("resize", beiScroll);
+ window.addEventListener("wheel", beiEigenemScrollen, { passive: true });
+ window.addEventListener("touchmove", beiEigenemScrollen, { passive: true });
+ window.addEventListener("keydown", beiEigenemScrollen);
+ return () => {
+ window.removeEventListener("scroll", beiScroll);
+ window.removeEventListener("resize", beiScroll);
+ window.removeEventListener("wheel", beiEigenemScrollen);
+ window.removeEventListener("touchmove", beiEigenemScrollen);
+ window.removeEventListener("keydown", beiEigenemScrollen);
+ };
+ }, [abschnitte]);
+
+ const springe = useCallback((id) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ gewaehlt.current = id;
+ setAktiv(id);
+ // Bei „Bewegung reduzieren" NICHT sanft scrollen: Der Browser führt einen
+ // sanften Bildlauf dann gar nicht aus, und der Sprung ginge ins Leere.
+ const ruhig = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ el.scrollIntoView({ behavior: ruhig ? "auto" : "smooth", block: "start" });
+ // Adresse mitführen, aber ohne History-Eintrag: Sonst müsste man nach
+ // fünf Klicks fünfmal zurück, um die Seite zu verlassen.
+ window.history.replaceState(null, "", `#${id}`);
+ }, []);
+
+ // Direktverweis (/settings#bankanbindung). Zweiter Versuch nach kurzer Zeit,
+ // weil Bankanbindung und Postfächer ihre Inhalte nachladen und die Seite
+ // dabei noch wächst – der erste Sprung landete sonst zu weit oben.
+ useEffect(() => {
+ const id = decodeURIComponent(window.location.hash.slice(1));
+ if (!id) return;
+ const hin = () => document.getElementById(id)?.scrollIntoView({ block: "start" });
+ hin();
+ const uhr = setTimeout(hin, 300);
+ return () => clearTimeout(uhr);
+ }, []);
+
async function keywordBehalten(id) {
try { await api.confirmKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
@@ -54,120 +143,151 @@ export default function Settings() {