import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import Icon from "../components/Icon";
import NavigationAuswahl from "../components/NavigationAuswahl";
import { useToast } from "../toast";
import BrandingUpload from "../components/BrandingUpload";
import DangerAction from "../components/DangerAction";
import { DATE_FORMAT_KEY, useSettings } from "../settings";
import { DATE_FORMATS, formatDate } from "../units";
import { applyFavicon } from "../branding";
const EXPIRY_KEY = "expiry_warning_days";
// Haltbarkeit nach dem Anbrechen. Der Wert ganz oben in der Kette: Artikel >
// Kategorie (naechster Vorfahr) > hier. Weil er die letzte Station ist, darf er
// nicht leer sein - sonst haette eine angebrochene Packung kein Verbrauchsdatum.
const OPEN_DAYS_KEY = "open_days";
const RECEIPT_KEY = "receipt_match_threshold";
const SESSION_KEY = "session_days";
// Feste Stufen statt eines Zahlenfeldes: "wie lange bleibe ich angemeldet" ist
// eine Entscheidung, keine Rechenaufgabe. 0 heisst unbegrenzt.
const SITZUNGSDAUERN = [
{ value: "7", label: "7 Tage" },
{ value: "30", label: "30 Tage" },
{ value: "90", label: "3 Monate" },
{ value: "180", label: "6 Monate" },
{ value: "365", label: "1 Jahr" },
{ value: "0", label: "Unbegrenzt – nie abmelden" },
];
export default function Settings() {
const { reload } = useSettings();
const toast = useToast();
const [days, setDays] = useState("");
const [openDays, setOpenDays] = useState("");
const [threshold, setThreshold] = useState("");
const [dateFormat, setDateFormat] = useState("de");
const [sessionDays, setSessionDays] = useState("30");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
async function load() {
try {
const settings = await api.listSettings();
setDays(settings.find((s) => s.key === EXPIRY_KEY)?.value ?? "7");
setOpenDays(settings.find((s) => s.key === OPEN_DAYS_KEY)?.value ?? "3");
setThreshold(settings.find((s) => s.key === RECEIPT_KEY)?.value ?? "45");
setDateFormat(settings.find((s) => s.key === DATE_FORMAT_KEY)?.value ?? "de");
setSessionDays(settings.find((s) => s.key === SESSION_KEY)?.value ?? "30");
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function save(e) {
e.preventDefault();
setError(null); setBusy(true);
try {
await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10)));
// Mindestens 1: "0 Tage" waere keine Frist, sondern ein Widerspruch.
await api.setSetting(OPEN_DAYS_KEY, String(Math.max(1, parseInt(openDays, 10) || 1)));
await api.setSetting(RECEIPT_KEY, String(Math.max(0, Math.min(100, parseInt(threshold, 10) || 0))));
await api.setSetting(DATE_FORMAT_KEY, dateFormat);
await api.setSetting(SESSION_KEY, sessionDays);
await reload();
toast("Gespeichert.");
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
// Ein ueber die API gesetzter Zwischenwert (z.B. 60) steht nicht in der Liste.
// Ohne diesen Zusatz zeigte die Auswahl nichts an und das Speichern haette den
// Wert klammheimlich veraendert.
const dauerAuswahl = SITZUNGSDAUERN.some((d) => d.value === sessionDays)
? SITZUNGSDAUERN
: [...SITZUNGSDAUERN, { value: sessionDays, label: `${sessionDays} Tage` }];
// Beispiel mit einem festen Datum, damit man die Wirkung sofort sieht.
const preview = formatDate("2026-12-23", dateFormat);
return (
Einstellungen
Allgemeine Konfiguration der Anwendung
{error &&
{error}
}
);
}
/**
* Welche Einträge der Seitenleiste es im Haushalt überhaupt gibt.
*
* Was hier abgewählt ist, sieht niemand – auch kein Administrator. Jeder kann
* in seinem Konto zusätzlich eigene Einträge ausblenden, aber nichts davon
* wieder einblenden.
*/
function SeitenleisteCard() {
const toast = useToast();
const [versteckt, setVersteckt] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
api.navigation().then((a) => setVersteckt(a.vorgabe || [])).catch((e) => setError(e.message));
}, []);
async function speichern(neu) {
setVersteckt(neu);
setBusy(true);
setError(null);
try {
await api.setzeNavigationsVorgabe(neu);
// Die Leiste horcht darauf, statt dass man neu laden muss.
window.dispatchEvent(new Event("navigation-geaendert"));
toast("Seitenleiste gespeichert.");
} catch (err) { setError(err.message); } finally { setBusy(false); }
}
return (
Seitenleiste
Was hier abgewählt ist, verschwindet für alle aus der Leiste. Die Seiten
selbst bleiben erreichbar – Lesezeichen und Links funktionieren weiter.
Übersicht und Einstellungen lassen sich nicht ausblenden.
{error &&
{error}
}
{versteckt == null
?
Wird geladen…
: }
);
}
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
/**
* Verbindung zu einer selbst gehosteten Mealie-Instanz.
*
* Das Passwort wird nicht gespeichert: Vorrania meldet sich damit einmal an,
* laesst sich von Mealie ein Dauertoken ausstellen und vergisst es. Deshalb
* wird das Feld nach dem Speichern geleert und nie wieder gefuellt – es gibt
* nichts, was man zurueckladen koennte.
*/
function MealieCard() {
const toast = useToast();
const confirm = useConfirm();
const [status, setStatus] = useState(null);
const [url, setUrl] = useState("");
const [benutzer, setBenutzer] = useState("");
const [passwort, setPasswort] = useState("");
const [token, setToken] = useState("");
const [mitToken, setMitToken] = useState(false);
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState(null);
// Das Formular steht nur offen, solange nichts eingerichtet ist – oder wenn
// man es ausdruecklich wieder aufmacht. Ein Dauerzustand gehoert nicht in ein
// Eingabeformular, sonst sieht man nie, ob es geklappt hat.
const [aendern, setAendern] = useState(false);
async function laden(pruefen = false) {
try {
const s = await api.mealieStatus(pruefen);
setStatus(s);
if (s.base_url) setUrl(s.base_url);
} catch (err) { setFehler(err.message); }
}
useEffect(() => { laden(); }, []);
const verbunden = !!status?.configured;
async function verbinden(e) {
e.preventDefault();
// Eigene Pruefung vor dem Absenden: Der Server antwortet sonst mit der
// rohen Schema-Meldung („String should have at least 1 character"), und
// die erklaert niemandem, welches Feld gemeint ist.
if (!url.trim()) { setFehler("Bitte die Adresse von Mealie eintragen."); return; }
if (mitToken && !token.trim()) { setFehler("Bitte das Token aus Mealie einfügen."); return; }
if (!mitToken && !benutzer.trim()) { setFehler("Bitte den Benutzernamen in Mealie eintragen."); return; }
if (!mitToken && !passwort) { setFehler("Bitte das Passwort eingeben."); return; }
setBusy(true);
setFehler(null);
try {
const s = mitToken
? await api.mealieToken({ base_url: url, token })
: await api.mealieKonfigurieren({ base_url: url, username: benutzer, password: passwort });
setStatus(s);
setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert
setToken("");
setAendern(false); // Formular zu, der Zustand oben sagt jetzt alles
toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht.");
} catch (err) { setFehler(err.message); } finally { setBusy(false); }
}
async function trennen() {
const ok = await confirm({
title: "Verbindung zu Mealie lösen?",
message: "Adresse und Token werden hier entfernt. In Mealie selbst bleibt das "
+ "Token bestehen – dort unter Profil ▸ API-Tokens löschbar.",
confirmLabel: "Trennen", danger: true,
});
if (!ok) return;
try {
await api.mealieTrennen();
setStatus({ configured: false, base_url: "", token_set: false });
setUrl(""); setBenutzer(""); setPasswort(""); setToken("");
setAendern(false);
toast("Verbindung gelöst.");
} catch (err) { setFehler(err.message); }
}
return (
);
}
function ApiTokensCard() {
const confirm = useConfirm();
const [tokens, setTokens] = useState([]);
const [name, setName] = useState("");
const [fresh, setFresh] = useState(null);
const [error, setError] = useState(null);
async function load() {
try {
setTokens(await api.listApiTokens());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function create(e) {
e.preventDefault();
setError(null);
try {
const created = await api.createApiToken({ name });
setFresh(created.token);
setName("");
load();
} catch (err) {
setError(err.message);
}
}
async function remove(token) {
const ok = await confirm({
title: `Token „${token.name}“ widerrufen?`,
message: "Zugriffe mit diesem Token funktionieren sofort nicht mehr.",
confirmLabel: "Widerrufen",
danger: true,
});
if (!ok) return;
try {
await api.deleteApiToken(token.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
API-Zugriff (z.B. Home Assistant)
{error &&
{error}
}
Mit einem Token lässt sich die Einkaufsliste dauerhaft abfragen, ohne dass eine
Anmeldung abläuft. Der Token wird nur einmal angezeigt – danach ist nur noch
sein Fingerabdruck gespeichert.
{fresh && (
Neuer Token – bitte jetzt kopieren:
{fresh}
)}
{tokens.map((t) => (
{t.name}
{" "}· angelegt {new Date(t.created_at).toLocaleDateString("de-DE")}
{t.last_used_at
? ` · zuletzt genutzt ${new Date(t.last_used_at).toLocaleDateString("de-DE")}`
: " · noch nicht genutzt"}
);
}
/**
* Gefahrenbereich: Daten in großem Stil löschen.
*
* Drei Stufen, damit man nicht mehr wegwirft als nötig. Benutzer, API-Tokens,
* Einstellungen und das eigene Logo bleiben in allen Stufen erhalten.
*/
function DangerZoneCard() {
const toast = useToast();
const [zahlen, setZahlen] = useState(null);
const [error, setError] = useState(null);
async function load() {
try {
setZahlen(await api.maintenanceSummary());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
function run(aktion, meldung) {
return async () => {
setError(null);
try {
setZahlen(await aktion());
toast(meldung);
} catch (err) {
setError(err.message);
}
};
}
if (!zahlen) return null;
const n = (wert, eins, mehr) => `${wert} ${wert === 1 ? eins : mehr}`;
return (
Gefahrenbereich
Diese Aktionen lassen sich nicht rückgängig machen. Lege vorher ein Backup an
(Import / Export → Backup als JSON). Benutzer, API-Tokens, Einstellungen und
dein Logo bleiben in allen Fällen erhalten.