diff --git a/web/src/pages/Rezepte.jsx b/web/src/pages/Rezepte.jsx index 3e782ab..a6e5429 100644 --- a/web/src/pages/Rezepte.jsx +++ b/web/src/pages/Rezepte.jsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; +import { Skeleton } from "../components/Skeleton"; import ZuordnungDialog from "../components/ZuordnungDialog"; import { fmt } from "../units"; @@ -41,6 +42,17 @@ export default function Rezepte() { const [laedt, setLaedt] = useState(true); // Welche Zutat gerade von Hand zugeordnet wird (null = keine). const [zuordnen, setZuordnen] = useState(null); + // Beim Drehen an den Portionen bleibt das Rezept stehen und nur die Werte + // werden neu gerechnet. Die Zutatenliste selbst aendert sich dabei nie - + // sie darf deshalb auch nicht verschwinden. + const [rechnet, setRechnet] = useState(false); + // Zaehlt die fertigen Ladevorgaenge und dient als React-Schluessel, damit die + // Einblend-Animation bei jedem neuen Ergebnis erneut anlaeuft. + const [stand, setStand] = useState(0); + // Laufende Nummer der Anfrage: Beim schnellen Klicken am Zahlenfeld koennen + // Antworten in falscher Reihenfolge eintreffen - nur die neueste zaehlt. + const anfrage = useRef(0); + const tippTimer = useRef(null); async function ladeListe(q = "") { setLaedt(true); @@ -52,30 +64,53 @@ export default function Rezepte() { } useEffect(() => { ladeListe(); }, []); - async function oeffne(s, wunschPortionen) { + /** + * Rezept laden. ``behalten`` heisst: Es ist dasselbe Rezept, nur mit anderen + * Portionen - dann bleibt alles stehen und nur die Werte gelten als unklar. + * Ohne das flackerte die ganze Seite bei jedem Druck auf den Zahlenpfeil. + */ + async function oeffne(s, wunschPortionen, { behalten = false } = {}) { setSlug(s); - setRezept(null); + if (behalten) setRechnet(true); + else setRezept(null); + const lauf = ++anfrage.current; try { const daten = await api.recipe(s, wunschPortionen); + if (lauf !== anfrage.current) return; // eine neuere Anfrage laeuft setRezept(daten); + setStand((n) => n + 1); // Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen. if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : ""); setFehler(null); - } catch (err) { setFehler(err.message); } + } catch (err) { + if (lauf === anfrage.current) setFehler(err.message); + } finally { + if (lauf === anfrage.current) setRechnet(false); + } } // Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein // Ziel, einen Bestand und einen Zustand. async function neuRechnen() { - if (slug) await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined); + if (slug) { + await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined, + { behalten: true }); + } } + // Das Zahlenfeld reagiert sofort, die Anfrage wartet kurz: Wer von 4 auf 12 + // tippt, soll nicht erst ein Ergebnis fuer 1 bekommen. function setzePortionen(wert) { setPortionen(wert); const zahl = Number(String(wert).replace(",", ".")); - if (slug && Number.isFinite(zahl) && zahl > 0) oeffne(slug, zahl); + if (tippTimer.current) clearTimeout(tippTimer.current); + if (!slug || !Number.isFinite(zahl) || zahl <= 0) return; + setRechnet(true); + tippTimer.current = setTimeout(() => oeffne(slug, zahl, { behalten: true }), 280); } + useEffect(() => () => { if (tippTimer.current) clearTimeout(tippTimer.current); }, []); + const fehlende = (rezept?.ingredients || []).filter( (z) => z.status === "fehlt" || z.status === "zu_wenig", ); @@ -129,10 +164,16 @@ export default function Rezepte() {

{rezept.name}

- - {rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`} - + {rechnet ? ( + + wird gerechnet… + + ) : ( + + {rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`} + + )}
@@ -158,8 +199,8 @@ export default function Rezepte() { {rezept.ingredients.map((z, i) => ( - {z.text} @@ -179,22 +220,40 @@ export default function Rezepte() { )} + {/* Nur die WERTE sind beim Umrechnen unklar, die Zutat + selbst nicht. Deshalb bleibt die Zeile stehen und nur + diese drei Zellen zeigen einen Platzhalter; das + Ergebnis blendet sich danach ein. */} - {z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"} - {z.benoetigt_base != null && ( -
- {fmt(z.benoetigt_base)} {kurz(z.basis_einheit)} -
+ {rechnet ? : ( + + {z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"} + {z.benoetigt_base != null && ( +
+ {fmt(z.benoetigt_base)} {kurz(z.basis_einheit)} +
+ )} +
)} - {z.bestand_base != null - ? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"} - {z.fehlt_text && ( -
fehlt: {z.fehlt_text}
+ {rechnet ? : ( + + {z.bestand_base != null + ? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"} + {z.fehlt_text && ( +
fehlt: {z.fehlt_text}
+ )} +
+ )} + + + {rechnet ? : ( + + + )} - ))} diff --git a/web/src/styles.css b/web/src/styles.css index 737b984..a2eb6f7 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -980,6 +980,19 @@ select.zeitraum:hover { color: var(--text); } animation: skeleton-glanz 1.4s ease infinite; border-radius: var(--radius-sm); } +/* Neu gerechnete Werte blenden sich ein, statt hart umzuspringen. Der + React-Schluessel (stand) laesst die Animation bei jedem Ergebnis neu + anlaufen. */ +.wert-ein { animation: wert-ein 0.28s ease both; } +@keyframes wert-ein { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: none; } +} +/* Wer Animationen abbestellt hat, bekommt den Wert sofort. */ +@media (prefers-reduced-motion: reduce) { + .wert-ein { animation: none; } + .skeleton { animation: none; } +} @keyframes skeleton-glanz { from { background-position: 100% 0; } to { background-position: 0 0; } @@ -1020,6 +1033,10 @@ select.zeitraum:hover { color: var(--text); } .kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-5); margin: 0; } .kv dt { color: var(--muted); font-size: 0.85rem; } .kv dd { margin: 0; } +/* Eine Knopfreihe direkt unter einer Angabenliste klebte am letzten Wert - + .kv hat bewusst keinen Aussenabstand, .field-inline nur einen nach unten. + Den fehlenden Abstand setzt deshalb die Geschwisterregel. */ +.kv + .field-inline { margin-top: var(--sp-4); } /* ---------- Utilities ---------- */ .muted { color: var(--muted); }