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}
}

Ablaufwarnung

Chargen, deren Mindesthaltbarkeitsdatum innerhalb dieser Frist liegt (oder bereits überschritten ist), erscheinen auf der Übersicht unter „Bald ablaufend“.

Wie lange eine angebrochene Packung noch gilt, wenn weder der Artikel noch eine seiner Kategorien etwas anderes sagt. Dieser Wert ist die letzte Station der Kette und deshalb Pflicht – ohne ihn bekäme eine offene Packung kein „zu verbrauchen bis“. Abweichungen stehen bei den Kategorien unter „Maße & Frist“.

Kassenzettel-Scan

Beim Scannen eines Kassenzettels (iOS) werden je Zeile nur Artikel vorgeschlagen, deren Übereinstimmung mindestens so hoch ist. Höher = strenger (weniger, dafür sicherere Vorschläge), niedriger = mehr Vorschläge.

Anmeldung

Gilt für die Website und die App gleichermaßen. Die Frist zählt ab dem letzten Besuch – wer regelmäßig vorbeischaut, wird also gar nicht abgemeldet. Geändert wird sie beim nächsten Seitenaufruf übernommen.

{sessionDays === "0" && (

Unbegrenzt heißt wirklich unbegrenzt: Eine einmal erteilte Anmeldung lässt sich nicht mehr einzeln zurückziehen. Wer alle Geräte aussperren will, muss JWT_SECRET in der .env ändern und den Server neu starten.

)}

Darstellung

Beispiel: {preview}

); } /** * 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 (

Mealie (Rezepte)

Verbindet Vorrania mit einer selbst gehosteten Mealie-Instanz, um Rezepte gegen den Bestand zu prüfen. Dein Passwort wird nicht{" "} gespeichert: Vorrania meldet sich damit einmal an, lässt sich ein Dauertoken ausstellen und vergisst es.

{fehler &&
{fehler}
} {verbunden && !aendern ? ( <>
{status.reachable === false ? `Nicht erreichbar: ${status.error || "unbekannter Grund"}` : "Verbunden"}
Adresse
{status.base_url}
{status.version && (<>
Mealie-Version
{status.version}
)}
Zugang
Dauertoken hinterlegt – dein Passwort ist nicht gespeichert.
) : ( <> {!mitToken ? (
) : ( )}
{verbunden && ( )}
)}
); } 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}
)}
setName(e.target.value)} required />
Beispiel für Home Assistant
{`# configuration.yaml
rest:
  - resource: http://DEIN-SERVER:8080/api/shopping-list
    scan_interval: 900
    headers:
      Authorization: "Bearer DEIN_TOKEN"
    sensor:
      - name: "Einkaufsliste Anzahl"
        value_template: "{{ value_json | length }}"
        json_attributes_path: "$"
      - name: "Einkaufsliste"
        value_template: "{{ value_json | map(attribute='name') | join(', ') }}"`}
); } /** * 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.

{error &&
{error}
}
); }