Files
certain/web/src/pages/Settings.jsx
T
GuidoandClaude Opus 5 7c53ee5dae feat: Einstellungen mit Abschnittsleiste statt Kachelfluss
Die Karten lagen in einem Spaltenfluss nebeneinander. Wo etwas landete, hing
von der Kartenhoehe ab und nicht vom Thema – man fand nichts wieder, und mit
jeder neuen Karte wurde es schlimmer.

Jetzt eine Spalte in fester Reihenfolge, daneben eine klebende Leiste mit den
Abschnitten: Sie zeigt, wo man gerade ist, und springt auf Klick dorthin. Die
Leiste wird aus derselben Liste gebaut wie die Abschnitte, damit kein Punkt
ins Leere zeigen kann. /settings#bankanbindung fuehrt direkt zum Abschnitt;
die Rueckkehr von der Bank nutzt das.

Drei Dinge, die beim Nachmessen auffielen und sonst kaputt gewesen waeren:

- Ein Klick setzt die Marke selbst. Ein kurzer Abschnitt am Seitenende bringt
  die Seite ans Ende, und die Seitenende-Regel haette dann einen anderen Punkt
  markiert als den angeklickten. Eigenes Scrollen hebt die Vorauswahl auf.
- Bei "Bewegung reduzieren" wird nicht sanft gescrollt: Der Browser fuehrt
  einen sanften Bildlauf dann gar nicht aus, der Sprung ginge ins Leere.
- Unter 760 px klebt die Abschnittsleiste nicht, weil dort die Hauptnavigation
  selbst die obere Leiste ist und sie verdeckt haette. Und die Spalten-
  Anordnung braucht align-items: stretch, sonst presst das flex-start von oben
  den Inhalt auf seine Inhaltsbreite statt auf die volle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 06:13:12 +02:00

294 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div>
<h1>{t("settings.title")}</h1>
<div className="settings-layout">
<nav className="settings-nav" aria-label={t("settings.sections")}>
<ul>
{abschnitte.map((a) => (
<li key={a.id}>
<a
href={`#${a.id}`}
className={aktiv === a.id ? "active" : ""}
aria-current={aktiv === a.id ? "true" : undefined}
onClick={(e) => { e.preventDefault(); springe(a.id); }}
>
<Icon name={a.icon} size={15} />
<span className="ellipsis">{a.label}</span>
</a>
</li>
))}
</ul>
</nav>
<div className="settings-inhalt">
<section id="allgemein" className="settings-section">
<div className="card">
<h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2>
<label>
<span>{t("settings.baseCurrency")}</span>
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
<option value="EUR">Euro (EUR)</option>
<option value="CHF">Schweizer Franken (CHF)</option>
</select>
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.baseCurrencyHint")}</p>
<label>
<span>{t("settings.locale")}</span>
<select value={s.locale} onChange={(e) => set("locale", e.target.value)}>
<option value="de">{t("settings.languageDe")}</option>
<option value="en">{t("settings.languageEn")}</option>
</select>
</label>
<label>
<span>{t("settings.theme")}</span>
<select value={s.theme} onChange={(e) => set("theme", e.target.value)}>
<option value="system">{t("settings.themeSystem")}</option>
<option value="light">{t("settings.themeLight")}</option>
<option value="dark">{t("settings.themeDark")}</option>
</select>
</label>
<label>
<span>{t("settings.budgetStartDay")}</span>
<input
type="number"
min="1"
max="31"
value={s.budget_start_day}
onChange={(e) => {
const day = Math.max(1, Math.min(31, Number(e.target.value) || 1));
set("budget_start_day", String(day));
}}
/>
</label>
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
</div>
</section>
{flagged.length > 0 && (
<section id="schlagwoerter" className="settings-section">
<div className="card" style={{ borderColor: "var(--danger)" }}>
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
<Icon name="alert" size={18} /> {t("settings.keywordReview")}
</h2>
<p className="muted small">{t("settings.keywordReviewHint")}</p>
{flagged.map((f) => (
<div className="kw-flag" key={f.id}>
<div className="kw-flag-main">
<div className="ellipsis">
<span className="kw-flag-word">{f.word}</span>
<Icon name="chevron-right" size={12} /> {f.category_name}
</div>
<div className="muted small">
{t("settings.keywordCorrected", {
overridden: f.overridden_count, applied: f.applied_count,
})}
</div>
</div>
<button className="btn sm" onClick={() => keywordBehalten(f.id)}>{t("settings.keep")}</button>
<button className="btn sm danger" onClick={() => keywordEntfernen(f.id)}>{t("settings.remove")}</button>
</div>
))}
</div>
</section>
)}
<section id="bankanbindung" className="settings-section"><Bankanbindung /></section>
<section id="postfaecher" className="settings-section"><Postfaecher /></section>
<section id="sicherung" className="settings-section">
<div className="card">
<h2 style={{ marginTop: 0 }}>{t("settings.backup")}</h2>
<p className="muted small">{t("settings.backupHint")}</p>
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
<Icon name="download" size={15} /> {t("settings.exportBackup")}
</button>
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.exportHint")}</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<button className="btn" onClick={() => dl(api.exportTransactionsCsv)}>
<Icon name="download" size={15} /> {t("settings.exportTxnCsv")}
</button>
<button className="btn" onClick={() => dl(api.exportTransactionsJson)}>
<Icon name="download" size={15} /> {t("settings.exportTxnJson")}
</button>
<button className="btn" onClick={() => dl(api.exportAllJson)}>
<Icon name="download" size={15} /> {t("settings.exportAll")}
</button>
</div>
{isAdmin && (
<>
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.dbBackupHint")}</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<button className="btn" onClick={() => dl(api.downloadDbBackup)}>
<Icon name="download" size={15} /> {t("settings.dbBackup")}
</button>
<button className="btn" onClick={() => dl(api.exportSystemBackupJson)}>
<Icon name="download" size={15} /> {t("settings.systemBackup")}
</button>
</div>
<div className="alert warn" style={{ marginTop: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{t("settings.systemBackupWarning")}</span>
</div>
</>
)}
</div>
</section>
<section id="wiederherstellen" className="settings-section"><RestoreCard /></section>
{isAdmin && (
<section id="richtlinien" className="settings-section"><PolicyCard /></section>
)}
</div>
</div>
</div>
);
}