Seitenzustand in die Adresszeile

Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.

useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.

Damit wandern in die URL:

- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
  benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
  und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
  verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
  (?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
  Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
  Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.

Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.

Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.

Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 16:13:37 +02:00
co-authored by Claude Opus 5
parent a1b10d9ec3
commit 18481bbca0
10 changed files with 270 additions and 28 deletions
+10
View File
@@ -38,7 +38,17 @@ def _fehler(fehler: mealie.MealieFehler) -> HTTPException:
503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" – 503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" –
die Oberfläche soll beides verschieden erklären können. die Oberfläche soll beides verschieden erklären können.
404 kam dazu, als das Rezept in den Pfad wanderte (/rezepte/<slug>): Seither
lässt sich ein Rezept verlinken, und ein alter Link zeigt irgendwann ins
Leere. Das ist kein Gateway-Fehler, sondern eine ganz gewöhnliche Auskunft –
und sie soll auch so klingen, nicht nach „Mealie kennt /api/recipes/x nicht".
""" """
if fehler.kennung == "nicht_gefunden":
return HTTPException(
status.HTTP_404_NOT_FOUND,
"Dieses Rezept gibt es in Mealie nicht (mehr).",
)
code = ( code = (
status.HTTP_503_SERVICE_UNAVAILABLE status.HTTP_503_SERVICE_UNAVAILABLE
if fehler.kennung == "nicht_eingerichtet" if fehler.kennung == "nicht_eingerichtet"
+22
View File
@@ -18,6 +18,7 @@ from app.routers.mealie import (
mealie_token_setzen, mealie_token_setzen,
mealie_trennen, mealie_trennen,
) )
from app.routers.recipes import get_recipe
from app.routers.settings import list_settings from app.routers.settings import list_settings
from app.schemas import MealieConfigIn, MealieTokenIn from app.schemas import MealieConfigIn, MealieTokenIn
from app.services import mealie from app.services import mealie
@@ -301,3 +302,24 @@ def test_pfadvariante_wird_gepuffert(db, admin):
mealie.pfadvariante(db) mealie.pfadvariante(db)
assert len(gesehen) == anzahl # keine zweite Anfrage assert len(gesehen) == anzahl # keine zweite Anfrage
def test_unbekanntes_rezept_ist_ein_404_mit_klarem_text(db, admin):
"""Seit das Rezept im Pfad steht, kann ein Link ins Leere zeigen.
Das ist eine gewoehnliche Auskunft und kein Gateway-Fehler - und der Text
landet in der Oberflaeche, darf also nicht nach Innenleben klingen.
"""
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
db.add(IntegrationSecret(service="mealie", secret="t"))
db.commit()
mealie.setze_transport(httpx.MockTransport(
lambda request: httpx.Response(404, json={"detail": "Not found"}),
))
with pytest.raises(HTTPException) as fehler:
get_recipe("gibtsnicht", db=db, _=admin)
assert fehler.value.status_code == 404
assert "Rezept" in str(fehler.value.detail)
assert "/api/" not in str(fehler.value.detail)
+1
View File
@@ -266,6 +266,7 @@ export default function App() {
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} /> <Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} /> <Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} /> <Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
<Route path="/rezepte/:slug" element={<Protected wide><Rezepte /></Protected>} />
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} /> <Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein- {/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
und Auslagern, keine Stammdatenänderung. */} und Auslagern, keine Stammdatenänderung. */}
+28 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useUrlWerte } from "../urlState";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
@@ -22,12 +23,28 @@ export default function Categories() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" }); const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" });
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Kategorie, deren Felder gerade verwaltet werden. // Welche Kategorie ihre Seitenkarte zeigt und welche davon: alles in der
const [feldKat, setFeldKat] = useState(null); // URL, damit ein Neuladen dieselbe Ansicht wiederherstellt. Gespeichert
// Welche Kategorie gerade ihre Masse und ihre Oeffnungsfrist zeigt. // wird die Kennung, nicht das Objekt - die Kategorien kommen erst vom
const [massKat, setMassKat] = useState(null); // Server, und ein Objekt laesst sich nicht in eine Adresse schreiben.
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. const [{ kat, panel, typ: typFilter }, setzeAnsicht] = useUrlWerte(
const [typFilter, setTypFilter] = useState(""); { kat: "", panel: "", typ: "" },
);
const katId = Number(kat) || null;
const gewaehlt = categories.find((c) => c.id === katId) ?? null;
const feldKat = panel === "felder" ? gewaehlt : null;
const massKat = panel === "masse" ? gewaehlt : null;
// Ein Klick aendert zwei Werte - deshalb EIN Schreibvorgang, sonst
// ueberschreibt der zweite den ersten.
function zeigeKarte(c, welche) {
const schonOffen = katId === c.id && panel === welche;
setzeAnsicht(schonOffen ? { kat: "", panel: "" } : { kat: c.id, panel: welche });
}
function schliesseKarte() {
setzeAnsicht({ kat: "", panel: "" });
}
const setTypFilter = (wert) => setzeAnsicht({ typ: wert });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
async function load() { async function load() {
@@ -82,7 +99,7 @@ export default function Categories() {
if (!ok) return; if (!ok) return;
try { try {
await api.deleteCategory(category.id); await api.deleteCategory(category.id);
if (feldKat && feldKat.id === category.id) setFeldKat(null); if (katId === category.id) schliesseKarte();
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -137,11 +154,11 @@ export default function Categories() {
render: (c) => ( render: (c) => (
<span className="cell-row" style={{ justifyContent: "flex-end" }}> <span className="cell-row" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Felder verwalten" <button className="btn-icon" title="Felder verwalten"
onClick={() => { setMassKat(null); setFeldKat(feldKat && feldKat.id === c.id ? null : c); }}> onClick={() => zeigeKarte(c, "felder")}>
<Icon name="tag" size={16} /> <Icon name="tag" size={16} />
</button> </button>
<button className="btn-icon" title="Maße & Frist" <button className="btn-icon" title="Maße & Frist"
onClick={() => { setFeldKat(null); setMassKat(massKat && massKat.id === c.id ? null : c); }}> onClick={() => zeigeKarte(c, "masse")}>
<Icon name="split" size={16} /> <Icon name="split" size={16} />
</button> </button>
{isAdmin && ( {isAdmin && (
@@ -225,7 +242,7 @@ export default function Categories() {
key={feldKat.id} key={feldKat.id}
category={feldKat} category={feldKat}
isAdmin={isAdmin} isAdmin={isAdmin}
onClose={() => setFeldKat(null)} onClose={schliesseKarte}
/> />
)} )}
@@ -234,7 +251,7 @@ export default function Categories() {
key={massKat.id} key={massKat.id}
category={massKat} category={massKat}
isAdmin={isAdmin} isAdmin={isAdmin}
onClose={() => setMassKat(null)} onClose={schliesseKarte}
onFrist={() => load()} onFrist={() => load()}
/> />
)} )}
+19 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useUrlZahl } from "../urlState";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
@@ -18,6 +19,11 @@ export default function CheckIn() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const toast = useToast(); const toast = useToast();
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
// Der gewaehlte Artikel steht als Kennung in der Adresse. Das Objekt selbst
// bleibt im Speicher - in die URL passt es nicht, und nachladen kann man es
// jederzeit. Damit ueberlebt die halbfertige Buchung ein Neuladen, und ein
// Link "lager diesen Artikel ein" laesst sich verschicken.
const [artikelId, setArtikelId] = useUrlZahl("artikel");
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [suggestion, setSuggestion] = useState(null); const [suggestion, setSuggestion] = useState(null);
// Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl"). // Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl").
@@ -59,6 +65,17 @@ export default function CheckIn() {
if (loc) setLocationId(loc); if (loc) setLocationId(loc);
}, [searchParams]); }, [searchParams]);
// Artikel aus der Adresse holen. Die Sperre verhindert, dass das Setzen der
// Kennung in selectProduct den Artikel gleich noch einmal laedt.
useEffect(() => {
if (!artikelId) { setProduct(null); return; }
if (product && product.id === artikelId) return;
api.getProduct(artikelId)
.then(selectProduct)
.catch(() => setArtikelId(null)); // geloescht oder Unsinn in der URL
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artikelId]);
function resetLookup() { function resetLookup() {
setSuggestion(null); setSuggestion(null);
setUnknownBarcode(null); setUnknownBarcode(null);
@@ -67,6 +84,7 @@ export default function CheckIn() {
function selectProduct(p) { function selectProduct(p) {
setProduct(p); setProduct(p);
setArtikelId(p.id);
setResults([]); setResults([]);
resetLookup(); resetLookup();
const opts = buildUnitOptions(p, units); const opts = buildUnitOptions(p, units);
@@ -375,7 +393,7 @@ export default function CheckIn() {
<div className="title">{product.name}</div> <div className="title">{product.name}</div>
<div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div> <div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
</div> </div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button> <button type="button" className="btn ghost" onClick={() => setArtikelId(null)}>Ändern</button>
</div> </div>
<div className="row"> <div className="row">
+19 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useUrlZahl } from "../urlState";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild"; import { ProduktThumb } from "../components/ProduktBild";
import { useToast } from "../toast"; import { useToast } from "../toast";
@@ -12,6 +13,11 @@ export default function CheckOut() {
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
// Der gewaehlte Artikel steht als Kennung in der Adresse. Das Objekt selbst
// bleibt im Speicher - in die URL passt es nicht, und nachladen kann man es
// jederzeit. Damit ueberlebt die halbfertige Buchung ein Neuladen, und ein
// Link "lager diesen Artikel ein" laesst sich verschicken.
const [artikelId, setArtikelId] = useUrlZahl("artikel");
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [lots, setLots] = useState([]); const [lots, setLots] = useState([]);
@@ -27,8 +33,20 @@ export default function CheckOut() {
api.listUnits().then(setUnits).catch(() => {}); api.listUnits().then(setUnits).catch(() => {});
}, []); }, []);
// Artikel aus der Adresse holen. Die Sperre verhindert, dass das Setzen der
// Kennung in selectProduct den Artikel gleich noch einmal laedt.
useEffect(() => {
if (!artikelId) { setProduct(null); return; }
if (product && product.id === artikelId) return;
api.getProduct(artikelId)
.then(selectProduct)
.catch(() => setArtikelId(null)); // geloescht oder Unsinn in der URL
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artikelId]);
async function selectProduct(p) { async function selectProduct(p) {
setProduct(p); setProduct(p);
setArtikelId(p.id);
setResults([]); setResults([]);
setLotId(""); setLotId("");
setQuantity(""); setQuantity("");
@@ -155,7 +173,7 @@ export default function CheckOut() {
)} )}
</div> </div>
</div> </div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button> <button type="button" className="btn ghost" onClick={() => setArtikelId(null)}>Ändern</button>
</div> </div>
<label> <label>
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import { useUrlZahl } from "../urlState";
import { useConfirm } from "../confirm"; import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList"; import BarcodeList from "../components/BarcodeList";
@@ -18,7 +19,9 @@ export default function Groups() {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "", min_stock: "", min_stock_unit_id: "", parent_ids: [], name: "", min_stock: "", min_stock_unit_id: "", parent_ids: [],
}); });
const [selectedId, setSelectedId] = useState(null); // Welche Gruppe ihre Seitenkarten zeigt - in der URL, damit ein Neuladen
// (und ein verschickter Link) dieselbe Gruppe wieder aufschlaegt.
const [selectedId, setSelectedId] = useUrlZahl("gruppe");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+4 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import { useUrlWert } from "../urlState";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
@@ -47,7 +48,9 @@ export default function Products({ fixedType = null }) {
// Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte. // Gruppen nur fuer die Pfad-Anzeige der Gruppen-Spalte.
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände. // "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
const [typ, setTyp] = useState(""); // In der URL, damit ein Neuladen die Auswahl behält und sich ein Stand
// verschicken lässt.
const [typ, setTyp] = useUrlWert("typ");
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+52 -13
View File
@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { Skeleton } from "../components/Skeleton"; import { Skeleton } from "../components/Skeleton";
import ZuordnungDialog from "../components/ZuordnungDialog"; import ZuordnungDialog from "../components/ZuordnungDialog";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useUrlWerte } from "../urlState";
import { fmt } from "../units"; import { fmt } from "../units";
/** /**
@@ -34,11 +36,23 @@ function Zustand({ status }) {
} }
export default function Rezepte() { export default function Rezepte() {
const navigate = useNavigate();
// Das gewaehlte Rezept steht im PFAD, nicht in einem Parameter: Es benennt,
// was man ansieht - so wie /products/:id und /i/:uid es schon tun.
const { slug: routeSlug } = useParams();
const slug = routeSlug || null;
// Suchbegriff der Liste und gewuenschte Portionen reisen als Parameter mit,
// damit ein Neuladen denselben Stand zeigt und sich ein Link verschicken
// laesst ("Pfannkuchen fuer 12").
const [{ q: urlSuche, portionen: urlPortionen }, setzeAnsicht] = useUrlWerte(
{ q: "", portionen: "" },
);
const [liste, setListe] = useState([]); const [liste, setListe] = useState([]);
const [suche, setSuche] = useState(""); // Eingabefelder: Sie folgen dem Tippen sofort, die URL erst verzoegert.
const [slug, setSlug] = useState(null); const [suche, setSuche] = useState(urlSuche);
const [rezept, setRezept] = useState(null); const [rezept, setRezept] = useState(null);
const [portionen, setPortionen] = useState(""); const [portionen, setPortionen] = useState(urlPortionen);
const [fehler, setFehler] = useState(null); const [fehler, setFehler] = useState(null);
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
// Welche Zutat gerade von Hand zugeordnet wird (null = keine). // Welche Zutat gerade von Hand zugeordnet wird (null = keine).
@@ -65,7 +79,10 @@ export default function Rezepte() {
setFehler(null); setFehler(null);
} catch (err) { setFehler(err.message); } finally { setLaedt(false); } } catch (err) { setFehler(err.message); } finally { setLaedt(false); }
} }
useEffect(() => { ladeListe(); }, []); // Die Liste haengt am Suchbegriff IN DER URL, nicht am Eingabefeld - sonst
// suchte jeder Tastendruck.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { ladeListe(urlSuche); }, [urlSuche]);
/** /**
* Rezept laden. ``behalten`` heisst: Es ist dasselbe Rezept, nur mit anderen * Rezept laden. ``behalten`` heisst: Es ist dasselbe Rezept, nur mit anderen
@@ -73,7 +90,6 @@ export default function Rezepte() {
* Ohne das flackerte die ganze Seite bei jedem Druck auf den Zahlenpfeil. * Ohne das flackerte die ganze Seite bei jedem Druck auf den Zahlenpfeil.
*/ */
async function oeffne(s, wunschPortionen, { behalten = false } = {}) { async function oeffne(s, wunschPortionen, { behalten = false } = {}) {
setSlug(s);
if (behalten) setRechnet(true); if (behalten) setRechnet(true);
else setRezept(null); else setRezept(null);
const lauf = ++anfrage.current; const lauf = ++anfrage.current;
@@ -82,7 +98,10 @@ export default function Rezepte() {
if (lauf !== anfrage.current) return; // eine neuere Anfrage laeuft if (lauf !== anfrage.current) return; // eine neuere Anfrage laeuft
setRezept(daten); setRezept(daten);
setStand((n) => n + 1); setStand((n) => n + 1);
// Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen. // Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen -
// aber NICHT in die URL schreiben. Dort steht nur, was jemand selbst
// eingestellt hat; sonst traegt jedes geoeffnete Rezept ein ?portionen
// mit sich herum, das gar keine Entscheidung ist.
if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : ""); if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : "");
setFehler(null); setFehler(null);
} catch (err) { } catch (err) {
@@ -92,13 +111,32 @@ export default function Rezepte() {
} }
} }
/** Portionen aus der URL als Zahl - unbrauchbares wird ignoriert. */
function gewuenschtePortionen() {
const zahl = Number(String(urlPortionen).replace(",", "."));
return Number.isFinite(zahl) && zahl > 0 ? zahl : undefined;
}
// Geladen wird, was in der Adresse steht - Rezept aus dem Pfad, Portionen
// aus dem Parameter. Damit ist ein Neuladen dasselbe wie ein Klick.
useEffect(() => {
if (!slug) { setRezept(null); return; }
oeffne(slug, gewuenschtePortionen(), { behalten: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug, urlPortionen]);
// Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein // Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein
// Ziel, einen Bestand und einen Zustand. // Ziel, einen Bestand und einen Zustand.
async function neuRechnen() { async function neuRechnen() {
if (slug) { if (slug) await oeffne(slug, gewuenschtePortionen(), { behalten: true });
await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined, }
{ behalten: true });
} /** Rezept oeffnen - der Suchbegriff der Liste bleibt dabei stehen. */
function oeffneRezept(s) {
navigate({
pathname: `/rezepte/${encodeURIComponent(s)}`,
search: urlSuche ? `?q=${encodeURIComponent(urlSuche)}` : "",
});
} }
// Das Zahlenfeld reagiert sofort, die Anfrage wartet kurz: Wer von 4 auf 12 // Das Zahlenfeld reagiert sofort, die Anfrage wartet kurz: Wer von 4 auf 12
@@ -109,7 +147,8 @@ export default function Rezepte() {
if (tippTimer.current) clearTimeout(tippTimer.current); if (tippTimer.current) clearTimeout(tippTimer.current);
if (!slug || !Number.isFinite(zahl) || zahl <= 0) return; if (!slug || !Number.isFinite(zahl) || zahl <= 0) return;
setRechnet(true); setRechnet(true);
tippTimer.current = setTimeout(() => oeffne(slug, zahl, { behalten: true }), 280); // Geschrieben wird in die URL; das Laden erledigt der Effekt darueber.
tippTimer.current = setTimeout(() => setzeAnsicht({ portionen: wert }), 280);
} }
useEffect(() => () => { if (tippTimer.current) clearTimeout(tippTimer.current); }, []); useEffect(() => () => { if (tippTimer.current) clearTimeout(tippTimer.current); }, []);
@@ -157,7 +196,7 @@ export default function Rezepte() {
<div className="card" style={{ marginBottom: "var(--sp-3)" }}> <div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<form className="field-inline" style={{ marginBottom: 0 }} <form className="field-inline" style={{ marginBottom: 0 }}
onSubmit={(e) => { e.preventDefault(); ladeListe(suche); }}> onSubmit={(e) => { e.preventDefault(); setzeAnsicht({ q: suche }); }}>
<input value={suche} placeholder="Rezept suchen…" <input value={suche} placeholder="Rezept suchen…"
onChange={(e) => setSuche(e.target.value)} /> onChange={(e) => setSuche(e.target.value)} />
<button className="btn"><Icon name="search" size={16} />Suchen</button> <button className="btn"><Icon name="search" size={16} />Suchen</button>
@@ -178,7 +217,7 @@ export default function Rezepte() {
<button type="button" <button type="button"
className={`btn sm ghost${r.slug === slug ? " primary" : ""}`} className={`btn sm ghost${r.slug === slug ? " primary" : ""}`}
style={{ width: "100%", justifyContent: "flex-start" }} style={{ width: "100%", justifyContent: "flex-start" }}
onClick={() => oeffne(r.slug, null)}> onClick={() => oeffneRezept(r.slug)}>
{r.name} {r.name}
</button> </button>
</li> </li>
+111
View File
@@ -0,0 +1,111 @@
/**
* Seitenzustand in der Adresszeile.
*
* Was man gerade ansieht, gehört in die URL und nicht nur in den Speicher der
* Komponente: Dann überlebt es ein Neuladen, lässt sich verschicken, und man
* sieht der Adresszeile an, wo man ist. Was man gerade *eingibt* gehört dagegen
* nicht hinein — Formularentwürfe, Passwörter, halbfertige Mengen.
*
* Zwei Regeln setzt der Hook durch, beide aus demselben Grund (die Adresszeile
* soll brauchbar bleiben):
*
* 1. **Immer `replace`.** Sonst legt jeder Tastendruck in einem Suchfeld einen
* Verlaufseintrag an, und die Zurück-Taste bräuchte zwanzig Versuche, um die
* Seite zu verlassen. Sie bleibt damit für die Sprünge frei, die zählen —
* etwa heraus aus dem Bearbeitungsmodus des Dashboards.
* 2. **Vorgabewerte verschwinden.** Steht der Wert auf dem Standard, fliegt der
* Parameter raus. Ohne das sammelt sich `?typ=&q=&kat=` an und ein geteilter
* Link ist nicht mehr zu lesen.
*
* Angefasst wird ausschließlich `useSearchParams` — nie `window.history`
* direkt, sonst bekommt der Router die Änderung nicht mit.
*/
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";
/**
* Ein einzelner Wert in der Adresszeile.
*
* Gibt `[wert, setzeWert]` zurück, wie `useState` — die Aufrufstelle merkt vom
* Unterschied nur, dass der Wert ein String ist.
*/
export function useUrlWert(schluessel, vorgabe = "") {
const [params, setParams] = useSearchParams();
const wert = params.get(schluessel) ?? vorgabe;
const setzeWert = useCallback(
(neu) => {
setParams(
(alt) => {
const naechste = new URLSearchParams(alt);
const text = neu == null ? "" : String(neu);
if (text === "" || text === String(vorgabe)) naechste.delete(schluessel);
else naechste.set(schluessel, text);
return naechste;
},
{ replace: true },
);
},
[schluessel, vorgabe, setParams],
);
return [wert, setzeWert];
}
/**
* Mehrere Werte, die zusammengehören — in EINEM Schreibvorgang.
*
* Zwei getrennte Aufrufe von `setzeWert` im selben Durchlauf können einander
* überschreiben: Beide bekommen denselben Ausgangsstand und der zweite macht
* den ersten rückgängig. Wo ein Klick zwei Dinge ändert („Kategorie 12, Karte
* Maße"), muss es deshalb ein Aufruf sein.
*
* `vorgaben` nennt die Schlüssel und ihre Standardwerte; übergeben wird beim
* Setzen nur, was sich ändert.
*/
export function useUrlWerte(vorgaben) {
const [params, setParams] = useSearchParams();
const werte = {};
for (const [schluessel, vorgabe] of Object.entries(vorgaben)) {
werte[schluessel] = params.get(schluessel) ?? vorgabe;
}
const setzeWerte = useCallback(
(aenderungen) => {
setParams(
(alt) => {
const naechste = new URLSearchParams(alt);
for (const [schluessel, neu] of Object.entries(aenderungen)) {
const text = neu == null ? "" : String(neu);
if (text === "" || text === String(vorgaben[schluessel] ?? "")) {
naechste.delete(schluessel);
} else {
naechste.set(schluessel, text);
}
}
return naechste;
},
{ replace: true },
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[setParams, JSON.stringify(vorgaben)],
);
return [werte, setzeWerte];
}
/**
* Dasselbe für eine Zahl (Artikel-, Kategorie-, Chargen-Kennungen).
*
* Unbrauchbares in der URL — `?kat=abc`, von Hand verstümmelt — ergibt `null`
* statt `NaN`. Eine kaputte Adresse soll die Seite nicht mit in den Abgrund
* ziehen, sondern einfach so aussehen, als stünde dort nichts.
*/
export function useUrlZahl(schluessel) {
const [roh, setzeRoh] = useUrlWert(schluessel);
const zahl = roh === "" ? null : Number(roh);
return [Number.isFinite(zahl) ? zahl : null, setzeRoh];
}