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 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()); } catch { /* still ignorieren – nicht kritisch für die Einstellungen */ } } 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"); } } async function keywordEntfernen(id) { try { await api.deleteKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); } catch (err) { toast(err.message, "error"); } } async function set(key, value) { try { await s.update(key, value); toast(t("settings.saved")); } catch (err) { toast(err.message, "error"); } } async function dl(fn) { try { await fn(); } catch (err) { toast(err.message, "error"); } } return (

{t("settings.title")}

{t("settings.general")}

{t("settings.baseCurrencyHint")}

{t("settings.budgetStartDayHint")}

{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.exportHint")}

{isAdmin && ( <>

{t("settings.dbBackupHint")}

{t("settings.systemBackupWarning")}
)}
{isAdmin && (
)}
); }