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() {

{t("settings.title")}

- {flagged.length > 0 && ( -
-

- {t("settings.keywordReview")} -

-

{t("settings.keywordReviewHint")}

- {flagged.map((f) => ( -
-
-
- {f.word} - {f.category_name} -
-
- {t("settings.keywordCorrected", { overridden: f.overridden_count, applied: f.applied_count })} -
-
- - +
+ + +
+
+
+

{t("settings.general")}

+ +

{t("settings.baseCurrencyHint")}

+ + + + + + +

{t("settings.budgetStartDayHint")}

- ))} -
- )} + - -
-

{t("settings.general")}

- -

{t("settings.baseCurrencyHint")}

+ {flagged.length > 0 && ( +
+
+

+ {t("settings.keywordReview")} +

+

{t("settings.keywordReviewHint")}

+ {flagged.map((f) => ( +
+
+
+ {f.word} + {f.category_name} +
+
+ {t("settings.keywordCorrected", { + overridden: f.overridden_count, applied: f.applied_count, + })} +
+
+ + +
+ ))} +
+
+ )} - +
+
- +
+
+

{t("settings.backup")}

+

{t("settings.backupHint")}

+ - -

{t("settings.budgetStartDayHint")}

-
+

{t("settings.exportHint")}

+
+ + + +
- {isAdmin && } + {isAdmin && ( + <> +

{t("settings.dbBackupHint")}

+
+ + +
+
+ + {t("settings.systemBackupWarning")} +
+ + )} +
+ -
-

{t("settings.backup")}

-

{t("settings.backupHint")}

- - -

{t("settings.exportHint")}

-
- - - -
+
{isAdmin && ( - <> -

{t("settings.dbBackupHint")}

-
- - -
-
- - {t("settings.systemBackupWarning")} -
- +
)}
- - - - -
+
); }