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>
This commit is contained in:
@@ -1194,3 +1194,116 @@ tr.row-dup {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
color: var(--accent);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,7 +106,8 @@
|
|||||||
"backupHint": "Das Voll-Backup enthält alle deine Daten und lässt sich unten wieder einspielen.",
|
"backupHint": "Das Voll-Backup enthält alle deine Daten und lässt sich unten wieder einspielen.",
|
||||||
"exportBackup": "Voll-Backup (JSON)",
|
"exportBackup": "Voll-Backup (JSON)",
|
||||||
"systemBackup": "Systemweites 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": {
|
"dashboard": {
|
||||||
"title": "Übersicht",
|
"title": "Übersicht",
|
||||||
|
|||||||
@@ -106,7 +106,8 @@
|
|||||||
"backupHint": "The full backup contains all of your data and can be restored below.",
|
"backupHint": "The full backup contains all of your data and can be restored below.",
|
||||||
"exportBackup": "Full backup (JSON)",
|
"exportBackup": "Full backup (JSON)",
|
||||||
"systemBackup": "System-wide 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": {
|
"dashboard": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function BankRueckkehr() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
|
<div className="btn-pair" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
<Link className="btn primary" to="/settings">
|
<Link className="btn primary" to="/settings#bankanbindung">
|
||||||
<Icon name="settings" size={15} /> {t("bankReturn.toSettings")}
|
<Icon name="settings" size={15} /> {t("bankReturn.toSettings")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+225
-105
@@ -1,22 +1,32 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import Kartenspalten from "../components/Kartenspalten";
|
|
||||||
import PolicyCard from "./settings/PolicyCard";
|
import PolicyCard from "./settings/PolicyCard";
|
||||||
import Bankanbindung from "./settings/Bankanbindung";
|
import Bankanbindung from "./settings/Bankanbindung";
|
||||||
import Postfaecher from "./settings/Postfaecher";
|
import Postfaecher from "./settings/Postfaecher";
|
||||||
import RestoreCard from "./settings/RestoreCard";
|
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() {
|
export default function Settings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const s = useSettings();
|
const s = useSettings();
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [flagged, setFlagged] = useState([]);
|
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() {
|
async function ladenFlagged() {
|
||||||
try { setFlagged(await api.flaggedKeywords()); }
|
try { setFlagged(await api.flaggedKeywords()); }
|
||||||
@@ -24,6 +34,85 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
useEffect(() => { ladenFlagged(); }, []);
|
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) {
|
async function keywordBehalten(id) {
|
||||||
try { await api.confirmKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); }
|
try { await api.confirmKeyword(id); setFlagged((f) => f.filter((x) => x.id !== id)); }
|
||||||
catch (err) { toast(err.message, "error"); }
|
catch (err) { toast(err.message, "error"); }
|
||||||
@@ -54,120 +143,151 @@ export default function Settings() {
|
|||||||
<div>
|
<div>
|
||||||
<h1>{t("settings.title")}</h1>
|
<h1>{t("settings.title")}</h1>
|
||||||
|
|
||||||
{flagged.length > 0 && (
|
<div className="settings-layout">
|
||||||
<div className="card" style={{ marginBottom: "var(--sp-4)", borderColor: "var(--danger)" }}>
|
<nav className="settings-nav" aria-label={t("settings.sections")}>
|
||||||
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
|
<ul>
|
||||||
<Icon name="alert" size={18} /> {t("settings.keywordReview")}
|
{abschnitte.map((a) => (
|
||||||
</h2>
|
<li key={a.id}>
|
||||||
<p className="muted small">{t("settings.keywordReviewHint")}</p>
|
<a
|
||||||
{flagged.map((f) => (
|
href={`#${a.id}`}
|
||||||
<div className="kw-flag" key={f.id}>
|
className={aktiv === a.id ? "active" : ""}
|
||||||
<div className="kw-flag-main">
|
aria-current={aktiv === a.id ? "true" : undefined}
|
||||||
<div className="ellipsis">
|
onClick={(e) => { e.preventDefault(); springe(a.id); }}
|
||||||
<span className="kw-flag-word">{f.word}</span>
|
>
|
||||||
<Icon name="chevron-right" size={12} /> {f.category_name}
|
<Icon name={a.icon} size={15} />
|
||||||
</div>
|
<span className="ellipsis">{a.label}</span>
|
||||||
<div className="muted small">
|
</a>
|
||||||
{t("settings.keywordCorrected", { overridden: f.overridden_count, applied: f.applied_count })}
|
</li>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
<button className="btn sm" onClick={() => keywordBehalten(f.id)}>{t("settings.keep")}</button>
|
</nav>
|
||||||
<button className="btn sm danger" onClick={() => keywordEntfernen(f.id)}>{t("settings.remove")}</button>
|
|
||||||
|
<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>
|
</div>
|
||||||
))}
|
</section>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Kartenspalten>
|
{flagged.length > 0 && (
|
||||||
<div className="card">
|
<section id="schlagwoerter" className="settings-section">
|
||||||
<h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2>
|
<div className="card" style={{ borderColor: "var(--danger)" }}>
|
||||||
<label>
|
<h2 style={{ display: "flex", alignItems: "center", gap: "var(--sp-2)", marginTop: 0 }}>
|
||||||
<span>{t("settings.baseCurrency")}</span>
|
<Icon name="alert" size={18} /> {t("settings.keywordReview")}
|
||||||
<select value={s.base_currency} onChange={(e) => set("base_currency", e.target.value)}>
|
</h2>
|
||||||
<option value="EUR">Euro (EUR)</option>
|
<p className="muted small">{t("settings.keywordReviewHint")}</p>
|
||||||
<option value="CHF">Schweizer Franken (CHF)</option>
|
{flagged.map((f) => (
|
||||||
</select>
|
<div className="kw-flag" key={f.id}>
|
||||||
</label>
|
<div className="kw-flag-main">
|
||||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.baseCurrencyHint")}</p>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<label>
|
<section id="bankanbindung" className="settings-section"><Bankanbindung /></section>
|
||||||
<span>{t("settings.locale")}</span>
|
<section id="postfaecher" className="settings-section"><Postfaecher /></section>
|
||||||
<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>
|
<section id="sicherung" className="settings-section">
|
||||||
<span>{t("settings.theme")}</span>
|
<div className="card">
|
||||||
<select value={s.theme} onChange={(e) => set("theme", e.target.value)}>
|
<h2 style={{ marginTop: 0 }}>{t("settings.backup")}</h2>
|
||||||
<option value="system">{t("settings.themeSystem")}</option>
|
<p className="muted small">{t("settings.backupHint")}</p>
|
||||||
<option value="light">{t("settings.themeLight")}</option>
|
<button className="btn primary" onClick={() => dl(api.exportBackupJson)}>
|
||||||
<option value="dark">{t("settings.themeDark")}</option>
|
<Icon name="download" size={15} /> {t("settings.exportBackup")}
|
||||||
</select>
|
</button>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
<p className="muted small" style={{ marginTop: "var(--sp-4)" }}>{t("settings.exportHint")}</p>
|
||||||
<span>{t("settings.budgetStartDay")}</span>
|
<div className="field-inline" style={{ flexWrap: "wrap" }}>
|
||||||
<input
|
<button className="btn" onClick={() => dl(api.exportTransactionsCsv)}>
|
||||||
type="number"
|
<Icon name="download" size={15} /> {t("settings.exportTxnCsv")}
|
||||||
min="1"
|
</button>
|
||||||
max="31"
|
<button className="btn" onClick={() => dl(api.exportTransactionsJson)}>
|
||||||
value={s.budget_start_day}
|
<Icon name="download" size={15} /> {t("settings.exportTxnJson")}
|
||||||
onChange={(e) => {
|
</button>
|
||||||
const day = Math.max(1, Math.min(31, Number(e.target.value) || 1));
|
<button className="btn" onClick={() => dl(api.exportAllJson)}>
|
||||||
set("budget_start_day", String(day));
|
<Icon name="download" size={15} /> {t("settings.exportAll")}
|
||||||
}}
|
</button>
|
||||||
/>
|
</div>
|
||||||
</label>
|
|
||||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && <PolicyCard />}
|
{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>
|
||||||
|
|
||||||
<div className="card">
|
<section id="wiederherstellen" className="settings-section"><RestoreCard /></section>
|
||||||
<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 && (
|
{isAdmin && (
|
||||||
<>
|
<section id="richtlinien" className="settings-section"><PolicyCard /></section>
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
<Bankanbindung />
|
|
||||||
<Postfaecher />
|
|
||||||
<RestoreCard />
|
|
||||||
</Kartenspalten>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user