diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index e0c9fcd..72d3f69 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -316,7 +316,7 @@ def timeline( Bekannte Ungenauigkeit: Die Umrechnung in Artikeleinheiten nutzt die *heutige* Packungsgröße. Wird sie später geändert, verschiebt sich auch die Historie. """ - days = max(2, min(days, 730)) + days = max(1, min(days, 730)) heute = date.today() produkte = db.query(Product) @@ -361,7 +361,7 @@ def activity( _: User = Depends(get_current_user), ) -> list[ActivityPoint]: """Anzahl der Ein- und Auslagerungen je Tag.""" - days = max(2, min(days, 365)) + days = max(1, min(days, 365)) heute = date.today() beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc) diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index 0489236..a89bac3 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -113,6 +113,7 @@ const PATHS = { ), check: , + close: , chevronRight: , box: ( <> diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index 9de426c..de6f75c 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -274,6 +274,9 @@ function KarteKategorienSaeulen() { /** Zeitraum-Auswahl für die Verlaufskarten. */ const ZEITRAEUME = [ + // 1 Tag ergibt einen einzelnen Wert – das Diagramm zeigt dann die blanke Zahl + // statt einer Linie aus einem Punkt. + { tage: 1, label: "1 Tag" }, { tage: 7, label: "7 Tage" }, { tage: 30, label: "30 Tage" }, { tage: 90, label: "90 Tage" }, diff --git a/web/src/dashboard/charts/Line.jsx b/web/src/dashboard/charts/Line.jsx index 9662e1f..4acd59c 100644 --- a/web/src/dashboard/charts/Line.jsx +++ b/web/src/dashboard/charts/Line.jsx @@ -2,19 +2,24 @@ import { useEffect, useRef, useState } from "react"; import { fmt } from "../../units"; import { tokenFarbe } from "../palette"; -/** Misst die tatsächliche Breite, damit das SVG in echten Pixeln zeichnet. - * Ein festes viewBox mit Skalierung würde Linienstärken verzerren. */ -function useBreite(ref) { - const [breite, setBreite] = useState(0); +/** Misst die tatsächliche Größe, damit das SVG in echten Pixeln zeichnet. + * Ein festes viewBox mit Skalierung würde Linienstärken verzerren. + * Auch die Höhe wird gemessen: Mit fester Höhe passte das Diagramm nicht mehr + * in die Karte, sobald darüber noch eine Auswahl stand – es gab einen Rollbalken. */ +function useGroesse(ref) { + const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 }); useEffect(() => { if (!ref.current) return undefined; const beobachter = new ResizeObserver(([eintrag]) => { - setBreite(eintrag.contentRect.width); + setGroesse({ + breite: eintrag.contentRect.width, + hoehe: eintrag.contentRect.height, + }); }); beobachter.observe(ref.current); return () => beobachter.disconnect(); }, [ref]); - return breite; + return groesse; } /** @@ -27,19 +32,39 @@ function useBreite(ref) { * labels: ["01.07.", …] – gemeinsame X-Achse * serien: [{ name, color, werte: number[] }] */ -export default function Line({ labels = [], serien = [], einheit = "", hoehe = 200 }) { +export default function Line({ labels = [], serien = [], einheit = "" }) { const wirt = useRef(null); - const breite = useBreite(wirt); + const plot = useRef(null); + const { breite, hoehe } = useGroesse(plot); const [index, setIndex] = useState(null); - const hatDaten = labels.length > 1 && serien.some((s) => s.werte?.length); - if (!hatDaten) { + const legende = serien.length > 1; + const hatWerte = serien.some((s) => s.werte?.length); + + // Ein einzelner Tag ergibt keine Linie – dann ist die blanke Zahl die + // ehrlichere Darstellung als eine Linie aus einem Punkt. + if (labels.length === 1 && hatWerte) { + return ( +
+ {serien.map((s) => ( +
+ + {fmt(s.werte[0] ?? 0)} + {einheit || s.name} +
+ ))} +
{labels[0]}
+
+ ); + } + + if (labels.length <= 1 || !hatWerte) { return
Keine Daten vorhanden.
; } const rand = { oben: 12, rechts: 12, unten: 24, links: 44 }; const w = Math.max(breite, 240); - const h = hoehe; + const h = Math.max(hoehe, 120); const flaecheB = Math.max(w - rand.links - rand.rechts, 10); const flaecheH = Math.max(h - rand.oben - rand.unten, 10); @@ -72,7 +97,7 @@ export default function Line({ labels = [], serien = [], einheit = "", hoehe = 2 return (
- {serien.length > 1 && ( + {legende && (
    {serien.map((s) => (
  • @@ -83,7 +108,7 @@ export default function Line({ labels = [], serien = [], einheit = "", hoehe = 2
)} -
+
setIndex(null)}> diff --git a/web/src/main.jsx b/web/src/main.jsx index 542cff3..f85b0cb 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -5,6 +5,7 @@ import App from "./App"; import { AuthProvider } from "./auth"; import { SettingsProvider } from "./settings"; import { ConfirmProvider } from "./confirm"; +import { ToastProvider } from "./toast"; import "./styles.css"; import { applyFavicon } from "./branding"; @@ -17,7 +18,9 @@ ReactDOM.createRoot(document.getElementById("root")).render( - + + + diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index f4a5eaa..3cc6114 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; +import { useToast } from "../toast"; import { suggestionToProduct } from "../offUtils"; import { asTree } from "../categoryTree"; import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units"; @@ -11,6 +12,7 @@ const emptyLine = () => ({ quantity: "", best_before: "" }); export default function CheckIn() { const { isAdmin } = useAuth(); + const toast = useToast(); const [barcode, setBarcode] = useState(""); const [product, setProduct] = useState(null); const [suggestion, setSuggestion] = useState(null); @@ -37,7 +39,6 @@ export default function CheckIn() { const [precision, setPrecision] = useState("day"); const [error, setError] = useState(null); - const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { @@ -67,13 +68,13 @@ export default function CheckIn() { } async function doLookup() { - setError(null); setInfo(null); resetLookup(); + setError(null); resetLookup(); if (!barcode) return; try { const res = await api.lookup(barcode.trim()); if (res.found && res.existing_product) { selectProduct(res.existing_product); - setInfo(`Produkt erkannt: ${res.existing_product.name}`); + toast(`Produkt erkannt: ${res.existing_product.name}`); } else { // Gruppe kommt ausschliesslich aus einem hinterlegten Gruppen-Code, // die Kategorie aus der Open-Food-Facts-Einordnung. @@ -96,7 +97,7 @@ export default function CheckIn() { selectProduct(created); const gruppe = groups.find((g) => String(g.id) === String(created.group_id)); const kategorie = categories.find((c) => String(c.id) === String(created.category_id)); - setInfo( + toast( `Produkt "${created.name}" angelegt` + (kategorie ? `, Kategorie "${kategorie.name}"` : "") + (gruppe @@ -224,7 +225,7 @@ export default function CheckIn() { async function submit(e) { e.preventDefault(); - setError(null); setInfo(null); + setError(null); const payloadLines = lines .filter((l) => Number(l.quantity) > 0) .map((l) => ({ @@ -243,7 +244,7 @@ export default function CheckIn() { setBusy(true); try { const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines }); - setInfo( + toast( `${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` + `${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}` ); @@ -260,7 +261,6 @@ export default function CheckIn() {

Einlagern

{error &&
{error}
} - {info &&
{info}
} {!product && (
diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx index b696b4e..cdbad76 100644 --- a/web/src/pages/CheckOut.jsx +++ b/web/src/pages/CheckOut.jsx @@ -1,11 +1,13 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; +import { useToast } from "../toast"; import { useSettings } from "../settings"; import { buildUnitOptions, fmt, isExpired, unitShort } from "../units"; export default function CheckOut() { const { formatBestBefore } = useSettings(); + const toast = useToast(); const [barcode, setBarcode] = useState(""); const [search, setSearch] = useState(""); const [results, setResults] = useState([]); @@ -18,7 +20,6 @@ export default function CheckOut() { const [lotId, setLotId] = useState(""); // "" = automatisch (FEFO) const [error, setError] = useState(null); - const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { @@ -39,7 +40,7 @@ export default function CheckOut() { } async function doLookup() { - setError(null); setInfo(null); + setError(null); if (!barcode) return; try { const res = await api.lookup(barcode.trim()); @@ -64,7 +65,7 @@ export default function CheckOut() { async function submit(e) { e.preventDefault(); - setError(null); setInfo(null); setBusy(true); + setError(null); setBusy(true); try { const res = await api.checkOut({ product_id: product.id, @@ -72,7 +73,7 @@ export default function CheckOut() { unit, lot_id: lotId === "" ? null : Number(lotId), }); - setInfo( + toast( `Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ` + `${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}` ); @@ -104,7 +105,6 @@ export default function CheckOut() {

Auslagern

{error &&
{error}
} - {info &&
{info}
} {!product && (
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 2eda4a0..9a836aa 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -4,6 +4,7 @@ import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; import { KARTEN, KARTEN_IDS } from "../dashboard/cards"; import Vorschau from "../dashboard/Vorschau"; @@ -25,6 +26,7 @@ const ZEILENHOEHE = 40; export default function Dashboard() { const { isAdmin } = useAuth(); const confirm = useConfirm(); + const toast = useToast(); const [layout, setLayout] = useState([]); const [entwurf, setEntwurf] = useState(null); // nur im Bearbeitungsmodus gesetzt @@ -32,7 +34,6 @@ export default function Dashboard() { const [erzwungen, setErzwungen] = useState(false); const [hatVorgabe, setHatVorgabe] = useState(false); const [fehler, setFehler] = useState(null); - const [hinweis, setHinweis] = useState(null); const [menuOffen, setMenuOffen] = useState(false); const bearbeiten = entwurf !== null; @@ -86,7 +87,6 @@ export default function Dashboard() { function starteBearbeitung() { setEntwurf(aktuell.map((k) => ({ ...k }))); - setHinweis(null); } function hinzufuegen(id) { @@ -107,7 +107,7 @@ export default function Dashboard() { setLayout(bereinige(antwort.layout)); setHerkunft(antwort.source); setEntwurf(null); - setHinweis("Anordnung gespeichert."); + toast("Anordnung gespeichert."); } catch (err) { setFehler(err.message); } @@ -118,7 +118,7 @@ export default function Dashboard() { try { await api.saveDashboardDefault(schlank(entwurf ?? layout)); setHatVorgabe(true); - setHinweis("Als Vorgabe für alle Benutzer gespeichert."); + toast("Als Vorgabe für alle Benutzer gespeichert."); } catch (err) { setFehler(err.message); } @@ -132,7 +132,7 @@ export default function Dashboard() { setLayout(bereinige(antwort.layout)); setHerkunft(antwort.source); setEntwurf(null); - setHinweis(wert + toast(wert ? "Die Startseite ist jetzt für alle verbindlich." : "Benutzer dürfen ihre Startseite wieder selbst anordnen."); } catch (err) { @@ -152,7 +152,7 @@ export default function Dashboard() { await api.resetDashboardLayout(); setEntwurf(null); await laden(); - setHinweis("Auf die Vorgabe zurückgesetzt."); + toast("Auf die Vorgabe zurückgesetzt."); } catch (err) { setFehler(err.message); } @@ -211,7 +211,6 @@ export default function Dashboard() {
{fehler &&
{fehler}
} - {hinweis &&
{hinweis}
} {bearbeiten && (
diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 9674877..5632382 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -5,6 +5,7 @@ import { useConfirm } from "../confirm"; import { useAuth } from "../auth"; import BarcodeList from "../components/BarcodeList"; import Icon from "../components/Icon"; +import { useToast } from "../toast"; import { useSettings } from "../settings"; import { asTree } from "../categoryTree"; import { @@ -30,6 +31,7 @@ const KIND_ORDER = ["count", "weight", "volume"]; export default function ProductForm() { const confirm = useConfirm(); + const toast = useToast(); const { id } = useParams(); const isNew = !id; const { isAdmin } = useAuth(); @@ -43,7 +45,6 @@ export default function ProductForm() { const [categories, setCategories] = useState([]); const [units, setUnits] = useState([]); const [error, setError] = useState(null); - const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. const [minUnit, setMinUnit] = useState(""); @@ -96,7 +97,7 @@ export default function ProductForm() { group_id: f.group_id || (res.group_id ? String(res.group_id) : ""), })); if (s.package_size != null) setMinUnit("package"); - setInfo( + toast( "Daten von Open Food Facts übernommen." + (s.package_size != null ? "" : " (Füllmenge nicht hinterlegt – bitte Packungsgröße prüfen.)") + (res.category_name ? ` Kategorie „${res.category_name}“ vorgeschlagen.` : "") + @@ -107,16 +108,16 @@ export default function ProductForm() { async function runLookup(code, unitList) { if (!code) return; setError(null); - setInfo(null); + try { const res = await api.lookup(code.trim()); if (res.found && res.existing_product) { - setInfo("Dieses Produkt existiert bereits."); + toast("Dieses Produkt existiert bereits."); navigate(`/products/${res.existing_product.id}`); } else if (res.found && res.suggestion) { applySuggestion(res, unitList); } else { - setInfo("Barcode unbekannt – bitte Daten selbst eingeben."); + toast("Barcode unbekannt – bitte Daten selbst eingeben.", "warn"); } } catch (err) { setError(err.message); @@ -210,7 +211,7 @@ export default function ProductForm() { navigate(`/products/${created.id}`); } else { await api.updateProduct(id, buildPayload()); - setInfo("Gespeichert."); + toast("Gespeichert."); setProduct(await api.getProduct(id)); setLots(await api.listLots(id)); } @@ -259,7 +260,6 @@ export default function ProductForm() {
{error &&
{error}
} - {info &&
{info}
}
@@ -430,6 +430,7 @@ export default function ProductForm() { /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) { const confirm = useConfirm(); + const toast = useToast(); const { formatBestBefore } = useSettings(); const [editId, setEditId] = useState(null); const [draft, setDraft] = useState({ quantity: "", best_before: "", precision: "day" }); diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 377e87a..28c6ec5 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; +import { useToast } from "../toast"; import BrandingUpload from "../components/BrandingUpload"; import DangerAction from "../components/DangerAction"; import { DATE_FORMAT_KEY, useSettings } from "../settings"; @@ -12,10 +13,10 @@ const EXPIRY_KEY = "expiry_warning_days"; export default function Settings() { const { reload } = useSettings(); + const toast = useToast(); const [days, setDays] = useState(""); const [dateFormat, setDateFormat] = useState("de"); const [error, setError] = useState(null); - const [info, setInfo] = useState(null); const [busy, setBusy] = useState(false); async function load() { @@ -32,12 +33,12 @@ export default function Settings() { async function save(e) { e.preventDefault(); - setError(null); setInfo(null); setBusy(true); + setError(null); setBusy(true); try { await api.setSetting(EXPIRY_KEY, String(parseInt(days, 10))); await api.setSetting(DATE_FORMAT_KEY, dateFormat); await reload(); - setInfo("Gespeichert."); + toast("Gespeichert."); } catch (err) { setError(err.message); } finally { @@ -57,7 +58,6 @@ export default function Settings() {
{error &&
{error}
} - {info &&
{info}
}

Ablaufwarnung

@@ -233,9 +233,9 @@ rest: * 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); - const [info, setInfo] = useState(null); async function load() { try { @@ -250,10 +250,9 @@ function DangerZoneCard() { function run(aktion, meldung) { return async () => { setError(null); - setInfo(null); try { setZahlen(await aktion()); - setInfo(meldung); + toast(meldung); } catch (err) { setError(err.message); } @@ -276,7 +275,6 @@ function DangerZoneCard() {

{error &&
{error}
} - {info &&
{info}
}
{error &&
{error}
} - {info &&
{info}
}
diff --git a/web/src/styles.css b/web/src/styles.css index cab0329..1ffcabb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -560,7 +560,10 @@ select.zeitraum { margin-top: 0; width: auto; min-width: 0; flex: 0 0 auto; alig /* ---------- Startseite: Kartentypen ---------- */ .card-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; } -.card-stack { display: flex; flex-direction: column; gap: var(--sp-3); height: 100%; } +/* Inhalt einer Karte, der die volle Höhe nutzen soll: Die Auswahl oben behält + ihre Größe, das Diagramm darunter bekommt den Rest. Ohne min-height:0 wächst + ein Flex-Kind über seinen Platz hinaus – genau das erzeugte den Rollbalken. */ +.card-stack { display: flex; flex-direction: column; gap: var(--sp-3); flex: 1; min-height: 0; } .kpi-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; } .kpi { flex: 1 1 120px; min-width: 0; } @@ -605,9 +608,16 @@ select.zeitraum { margin-top: 0; width: auto; min-width: 0; flex: 0 0 auto; alig .bar-total { font-size: 0.8rem; color: var(--muted); font-variant-numeric: tabular-nums; } .chart-hint { font-size: 0.8rem; color: var(--muted); margin-top: var(--sp-2); } -.chart-line { width: 100%; } -.line-plot { position: relative; } +.chart-line { width: 100%; display: flex; flex-direction: column; flex: 1; min-height: 0; } +/* Der Zeichenbereich nimmt den verbleibenden Platz – seine Groesse wird + gemessen, das SVG zeichnet danach in echten Pixeln. */ +.line-plot { position: relative; flex: 1; min-height: 100px; } .line-plot svg { display: block; } + +/* Ein einzelner Tag: blanke Zahl statt Linie aus einem Punkt. */ +.chart-line.einzelwert { align-items: center; justify-content: center; gap: var(--sp-2); } +.einzel-reihe { display: flex; align-items: baseline; gap: var(--sp-2); } +.einzel-zahl { font-size: 2rem; font-weight: 680; letter-spacing: -0.02em; } .line-tooltip { position: absolute; top: 4px; transform: translateX(-50%); background: var(--surface); border: 1px solid var(--border-strong); @@ -618,6 +628,41 @@ select.zeitraum { margin-top: 0; width: auto; min-width: 0; flex: 0 0 auto; alig .line-tooltip .tip-row { display: flex; align-items: center; gap: 6px; } .line-tooltip .tip-name { color: var(--muted); } +/* ---------- Kurzmeldungen (Overlay unten) ---------- + Liegt ueber dem Inhalt statt in ihm: So verschiebt eine Meldung beim + Speichern nicht das ganze Layout. */ +.toasts { + position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); + z-index: 200; display: flex; flex-direction: column; gap: var(--sp-2); + align-items: center; pointer-events: none; + width: max-content; max-width: min(92vw, 520px); +} +.toast { + pointer-events: auto; + display: flex; align-items: center; gap: var(--sp-2); + padding: 10px var(--sp-3) 10px var(--sp-4); + border-radius: var(--radius); font-size: 0.875rem; + background: var(--surface); color: var(--text); + border: 1px solid var(--border-strong); + box-shadow: 0 8px 24px rgba(16, 24, 40, 0.18); + animation: toast-auf 160ms ease-out; +} +.toast.ok { border-left: 3px solid var(--ok); } +.toast.ok .icon { color: var(--ok); } +.toast.warn { border-left: 3px solid var(--warn); } +.toast.warn .icon { color: var(--warn); } +.toast-text { flex: 1; min-width: 0; } +.toast .btn-icon { flex: 0 0 auto; } + +@keyframes toast-auf { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */ +@media (prefers-reduced-motion: reduce) { + .toast { animation: none; } +} + /* ---------- Login ---------- */ .login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); } .login-card { width: 100%; max-width: 360px; } diff --git a/web/src/toast.jsx b/web/src/toast.jsx new file mode 100644 index 0000000..b813a33 --- /dev/null +++ b/web/src/toast.jsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import Icon from "./components/Icon"; + +/** + * Kurze Rückmeldungen als Overlay am unteren Rand. + * + * Vorher standen sie als Hinweisleiste mitten in der Seite – dadurch sprang + * beim Speichern das ganze Layout ein Stück nach unten. Ein Overlay liegt über + * dem Inhalt und verschiebt nichts. + * + * Bewusst nur für *Erfolgsmeldungen*: Fehler bleiben als Leiste an ihrer Stelle + * stehen. Eine Fehlermeldung, die nach fünf Sekunden verschwindet, ist genau + * dann weg, wenn man sie noch braucht. + * + * const toast = useToast(); + * toast("Gespeichert."); + */ +const ToastContext = createContext(() => {}); + +const DAUER = 5000; + +export function ToastProvider({ children }) { + const [meldungen, setMeldungen] = useState([]); + const uhren = useRef(new Map()); + + const entfernen = useCallback((id) => { + setMeldungen((alt) => alt.filter((m) => m.id !== id)); + const uhr = uhren.current.get(id); + if (uhr) { + clearTimeout(uhr); + uhren.current.delete(id); + } + }, []); + + const zeigen = useCallback((text, art = "ok") => { + if (!text) return; + const id = `${Date.now()}-${Math.random()}`; + setMeldungen((alt) => [...alt, { id, text, art }]); + uhren.current.set(id, setTimeout(() => entfernen(id), DAUER)); + }, [entfernen]); + + // Beim Verlassen der Seite laufende Uhren abräumen. + useEffect(() => { + const laufend = uhren.current; + return () => laufend.forEach((uhr) => clearTimeout(uhr)); + }, []); + + return ( + + {children} + {/* aria-live: Bildschirmleser sagen die Meldung an, ohne den Fokus zu stehlen. */} +
+ {meldungen.map((m) => ( +
+ + {m.text} + +
+ ))} +
+
+ ); +} + +export function useToast() { + return useContext(ToastContext); +}