Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-, Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb. Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) - Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) - PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin. Module: - Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery - Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen - Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts - Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen - Essensplanung und Wochenbudget mit Sportbonus - Einkaufslisten mit Maerkten und Laufweg-Sortierung - Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung - Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
451 lines
23 KiB
TypeScript
451 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowPathIcon,
|
|
CameraIcon,
|
|
CheckCircleIcon,
|
|
HandThumbDownIcon,
|
|
HandThumbUpIcon,
|
|
MagnifyingGlassIcon,
|
|
PlusIcon,
|
|
ScaleIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import { useState } from "react";
|
|
import { parseNutritionLabel, PortionUnit, unitLabel } from "@/lib/hellth-data";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
const portionUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
|
|
|
|
type FoodForm = {
|
|
name: string;
|
|
brand: string;
|
|
category: string;
|
|
kcalPer100g: string;
|
|
proteinPer100g: string;
|
|
carbsPer100g: string;
|
|
fatPer100g: string;
|
|
servingG: string;
|
|
preferredUnit: PortionUnit;
|
|
densityGPerMl: string;
|
|
portionLabel: string;
|
|
portionUnit: PortionUnit;
|
|
portionAmount: string;
|
|
portionGramsEquivalent: string;
|
|
barcode: string;
|
|
availableEverywhere: boolean;
|
|
};
|
|
|
|
const emptyForm: FoodForm = {
|
|
name: "",
|
|
brand: "",
|
|
category: "Sonstiges",
|
|
kcalPer100g: "",
|
|
proteinPer100g: "",
|
|
carbsPer100g: "",
|
|
fatPer100g: "",
|
|
servingG: "100",
|
|
preferredUnit: "g",
|
|
densityGPerMl: "",
|
|
portionLabel: "",
|
|
portionUnit: "piece",
|
|
portionAmount: "1",
|
|
portionGramsEquivalent: "",
|
|
barcode: "",
|
|
availableEverywhere: true,
|
|
};
|
|
|
|
function toNumber(value: string) {
|
|
return Number(value.replace(",", ".")) || 0;
|
|
}
|
|
|
|
function formFromFood(food: Record<string, unknown>): FoodForm {
|
|
return {
|
|
...emptyForm,
|
|
name: String(food.name ?? ""),
|
|
brand: String(food.brand ?? ""),
|
|
category: String(food.category ?? "Sonstiges"),
|
|
kcalPer100g: food.kcalPer100g != null ? String(food.kcalPer100g) : "",
|
|
proteinPer100g: food.proteinPer100g != null ? String(food.proteinPer100g) : "",
|
|
carbsPer100g: food.carbsPer100g != null ? String(food.carbsPer100g) : "",
|
|
fatPer100g: food.fatPer100g != null ? String(food.fatPer100g) : "",
|
|
servingG: food.servingG != null ? String(food.servingG) : "100",
|
|
barcode: String(food.barcode ?? ""),
|
|
};
|
|
}
|
|
|
|
export default function LebensmittelPage() {
|
|
const utils = trpc.useUtils();
|
|
const [query, setQuery] = useState("");
|
|
const [form, setForm] = useState<FoodForm>(emptyForm);
|
|
const [barcode, setBarcode] = useState("");
|
|
const [ocrText, setOcrText] = useState("");
|
|
const [status, setStatus] = useState<string | null>(null);
|
|
const [ocrBusy, setOcrBusy] = useState(false);
|
|
const [lookupBusy, setLookupBusy] = useState(false);
|
|
const [sourceFoodId, setSourceFoodId] = useState<string | null>(null);
|
|
const [sourceQuery, setSourceQuery] = useState("");
|
|
|
|
const foodsQuery = trpc.health.listFoods.useQuery({ query });
|
|
const sourceSearchQuery = trpc.health.searchOpenFoodFacts.useQuery(
|
|
{ query: sourceQuery, pageSize: 8 },
|
|
{ enabled: sourceQuery.trim().length >= 2 },
|
|
);
|
|
const saveFoodMutation = trpc.health.saveFood.useMutation({
|
|
onSuccess: async (result) => {
|
|
await utils.health.listFoods.invalidate();
|
|
setForm(emptyForm);
|
|
setStatus(`Lebensmittel gespeichert (${result.id}).`);
|
|
},
|
|
});
|
|
const addNutritionSourceMutation = trpc.health.addNutritionSource.useMutation({
|
|
onSuccess: async (result) => {
|
|
await utils.health.listFoods.invalidate();
|
|
setStatus(`Quelle gespeichert. Neuer Durchschnitt aus ${result.sourceCount} Quelle(n): ${result.average.kcalPer100g} kcal/100 g.`);
|
|
},
|
|
});
|
|
const setFoodPreferenceMutation = trpc.health.setFoodPreference.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.health.listFoods.invalidate();
|
|
await utils.health.recipeRecommendations.invalidate();
|
|
},
|
|
});
|
|
|
|
const foods = foodsQuery.data ?? [];
|
|
|
|
function saveFood(source: "manual" | "openfoodfacts" | "ocr" = "manual") {
|
|
if (!form.name.trim()) return;
|
|
saveFoodMutation.mutate({
|
|
name: form.name.trim(),
|
|
brand: form.brand.trim() || null,
|
|
category: form.category.trim() || "Sonstiges",
|
|
kcalPer100g: toNumber(form.kcalPer100g),
|
|
proteinPer100g: toNumber(form.proteinPer100g),
|
|
carbsPer100g: toNumber(form.carbsPer100g),
|
|
fatPer100g: toNumber(form.fatPer100g),
|
|
servingG: toNumber(form.servingG) || 100,
|
|
preferredUnit: form.preferredUnit,
|
|
densityGPerMl: form.densityGPerMl.trim() ? toNumber(form.densityGPerMl) : null,
|
|
barcode: form.barcode.trim() || null,
|
|
source,
|
|
availableEverywhere: form.availableEverywhere,
|
|
portions: form.portionLabel.trim()
|
|
? [{
|
|
label: form.portionLabel.trim(),
|
|
unit: form.portionUnit,
|
|
amount: toNumber(form.portionAmount) || 1,
|
|
gramsEquivalent: toNumber(form.portionGramsEquivalent) || toNumber(form.servingG) || 100,
|
|
}]
|
|
: [],
|
|
});
|
|
}
|
|
|
|
async function importFromOpenFoodFacts() {
|
|
const cleanBarcode = barcode.replace(/\D/g, "");
|
|
if (!cleanBarcode) return;
|
|
setLookupBusy(true);
|
|
setStatus(null);
|
|
try {
|
|
const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json?fields=product_name,brands,categories,nutriments,serving_quantity`);
|
|
const result = await response.json() as {
|
|
status?: number;
|
|
product?: {
|
|
product_name?: string;
|
|
brands?: string;
|
|
categories?: string;
|
|
serving_quantity?: number;
|
|
nutriments?: Record<string, number>;
|
|
};
|
|
};
|
|
if (result.status !== 1 || !result.product) {
|
|
setStatus("Kein Produkt für diesen Barcode gefunden.");
|
|
return;
|
|
}
|
|
const nutriments = result.product.nutriments ?? {};
|
|
setForm(formFromFood({
|
|
name: result.product.product_name || "Unbenanntes Produkt",
|
|
brand: result.product.brands?.split(",")[0]?.trim(),
|
|
category: result.product.categories?.split(",")[0]?.trim() || "Supermarkt",
|
|
kcalPer100g: nutriments["energy-kcal_100g"] ?? 0,
|
|
proteinPer100g: nutriments.proteins_100g ?? 0,
|
|
carbsPer100g: nutriments.carbohydrates_100g ?? 0,
|
|
fatPer100g: nutriments.fat_100g ?? 0,
|
|
servingG: result.product.serving_quantity ?? 100,
|
|
barcode: cleanBarcode,
|
|
}));
|
|
setStatus("Open-Food-Facts-Daten geladen. Bitte prüfen und speichern.");
|
|
} catch {
|
|
setStatus("Open Food Facts konnte gerade nicht abgefragt werden.");
|
|
} finally {
|
|
setLookupBusy(false);
|
|
}
|
|
}
|
|
|
|
async function readLabelImage(file: File) {
|
|
setOcrBusy(true);
|
|
setStatus("OCR läuft im Browser. Das kann einen Moment dauern.");
|
|
try {
|
|
const { recognize } = await import("tesseract.js");
|
|
const result = await recognize(file, "deu+eng");
|
|
const text = result.data.text;
|
|
const parsed = parseNutritionLabel(text);
|
|
setOcrText(text);
|
|
setForm((current) => ({
|
|
...current,
|
|
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
|
|
}));
|
|
setStatus("OCR abgeschlossen. Werte bitte kontrollieren und speichern.");
|
|
} catch {
|
|
setStatus("OCR konnte nicht abgeschlossen werden. Du kannst den Labeltext unten einfügen.");
|
|
} finally {
|
|
setOcrBusy(false);
|
|
}
|
|
}
|
|
|
|
function parsePastedOcr() {
|
|
const parsed = parseNutritionLabel(ocrText);
|
|
setForm((current) => ({
|
|
...current,
|
|
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
|
|
}));
|
|
setStatus("Text ausgewertet. Werte bitte kontrollieren und speichern.");
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
|
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
|
|
<p className="text-sm font-black uppercase text-[#0F766E]">Health-API</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Lebensmittelkatalog</h1>
|
|
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
|
Eigene Basisdaten, Supermarktprodukte, Barcode-Import und Label-OCR. Lebensmittel werden jetzt serverseitig pro Benutzer gespeichert.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-[1fr_0.9fr]">
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<div className="flex items-center gap-3 rounded-full bg-gray-100 px-4 py-3">
|
|
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Lebensmittel, Marke, Kategorie oder Barcode suchen"
|
|
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
|
{foods.map((food) => (
|
|
<article key={food.id} className="rounded-3xl bg-gray-50 p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-lg font-black text-gray-950">{food.name}</p>
|
|
<p className="text-sm font-bold text-gray-400">{food.brand || food.category} - {Number(food.servingG)} g Portion</p>
|
|
</div>
|
|
{food.isVerified ? <CheckCircleIcon className="h-6 w-6 text-emerald-600" /> : null}
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-4 gap-2 text-center">
|
|
<div><p className="text-xl font-black">{Number(food.kcalPer100g)}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
|
|
<div><p className="text-xl font-black">{Number(food.proteinPer100g)}</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
|
|
<div><p className="text-xl font-black">{Number(food.carbsPer100g)}</p><p className="text-xs font-bold text-gray-400">KH</p></div>
|
|
<div><p className="text-xl font-black">{Number(food.fatPer100g)}</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
<span className="rounded-full bg-white px-3 py-1 text-xs font-bold uppercase text-gray-300">{food.source}</span>
|
|
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
|
|
{food.availableEverywhere ? "überall erhältlich" : "Marktzuordnung"}
|
|
</span>
|
|
{food.portions?.map((portion) => (
|
|
<span key={portion.id} className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold text-teal-700">
|
|
{portion.label}: {Number(portion.amount)} {unitLabel(portion.unit)} = {Number(portion.gramsEquivalent)} g
|
|
</span>
|
|
))}
|
|
</div>
|
|
<div className="mt-3 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === 1 ? 0 : 1 })}
|
|
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
|
|
food.userVote === 1 ? "bg-emerald-600 text-white" : "bg-white text-emerald-700"
|
|
}`}
|
|
>
|
|
<HandThumbUpIcon className="h-4 w-4" />
|
|
Mag ich
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === -1 ? 0 : -1 })}
|
|
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
|
|
food.userVote === -1 ? "bg-red-600 text-white" : "bg-white text-red-700"
|
|
}`}
|
|
>
|
|
<HandThumbDownIcon className="h-4 w-4" />
|
|
Nicht meins
|
|
</button>
|
|
</div>
|
|
<div className="mt-3 rounded-2xl bg-white p-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-xs font-black uppercase text-gray-400">Quellen</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSourceFoodId(sourceFoodId === food.id ? null : food.id);
|
|
setSourceQuery(food.name);
|
|
}}
|
|
className="inline-flex items-center gap-1 rounded-full bg-gray-950 px-3 py-1 text-xs font-black text-white"
|
|
>
|
|
<ScaleIcon className="h-4 w-4" />
|
|
Mittelwert
|
|
</button>
|
|
</div>
|
|
{food.nutritionSources?.length ? (
|
|
<div className="mt-2 space-y-1">
|
|
{food.nutritionSources.map((source) => (
|
|
<a
|
|
key={source.id}
|
|
href={source.url ?? "#"}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="block rounded-xl bg-gray-50 px-3 py-2 text-xs font-bold text-gray-500 hover:bg-gray-100"
|
|
>
|
|
{source.provider}: {Number(source.kcalPer100g)} kcal/100 g
|
|
</a>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="mt-2 text-xs font-bold text-gray-300">Noch keine einzelnen Quellen hinterlegt.</p>
|
|
)}
|
|
{sourceFoodId === food.id ? (
|
|
<div className="mt-3 space-y-2">
|
|
<input
|
|
value={sourceQuery}
|
|
onChange={(event) => setSourceQuery(event.target.value)}
|
|
placeholder="Open Food Facts suchen"
|
|
className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-semibold"
|
|
/>
|
|
{(sourceSearchQuery.data ?? []).map((source) => (
|
|
<button
|
|
key={`${source.provider}-${source.providerFoodId ?? source.name}`}
|
|
type="button"
|
|
onClick={() => addNutritionSourceMutation.mutate({ foodId: food.id, ...source })}
|
|
className="block w-full rounded-2xl bg-emerald-50 px-3 py-2 text-left text-xs font-bold text-emerald-900 hover:bg-emerald-100"
|
|
>
|
|
{source.name} · {source.brand || "ohne Marke"} · {source.kcalPer100g} kcal/100 g
|
|
</button>
|
|
))}
|
|
{sourceSearchQuery.isFetching ? <p className="text-xs font-bold text-gray-300">Suche läuft...</p> : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</article>
|
|
))}
|
|
{!foods.length && !foodsQuery.isLoading ? (
|
|
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Lebensmittel gefunden.</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="space-y-4">
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Produkt importieren</h2>
|
|
<div className="mt-4 grid grid-cols-[1fr_52px] gap-2">
|
|
<input
|
|
value={barcode}
|
|
onChange={(event) => setBarcode(event.target.value)}
|
|
placeholder="Barcode / EAN"
|
|
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void importFromOpenFoodFacts()}
|
|
disabled={lookupBusy}
|
|
className="grid place-items-center rounded-full bg-gray-950 text-white disabled:opacity-50"
|
|
aria-label="Barcode suchen"
|
|
title="Barcode suchen"
|
|
>
|
|
<ArrowPathIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Label per OCR</h2>
|
|
<label className="mt-4 flex cursor-pointer items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
|
|
<CameraIcon className="h-5 w-5" />
|
|
Foto auswählen
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) void readLabelImage(file);
|
|
event.currentTarget.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
<textarea
|
|
value={ocrText}
|
|
onChange={(event) => setOcrText(event.target.value)}
|
|
placeholder="Oder OCR-/Labeltext hier einfügen..."
|
|
className="mt-3 min-h-28 w-full rounded-3xl border border-gray-200 px-4 py-3 text-sm"
|
|
/>
|
|
<button type="button" onClick={parsePastedOcr} disabled={!ocrText.trim() || ocrBusy} className="mt-3 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40">
|
|
Text auswerten
|
|
</button>
|
|
</section>
|
|
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Lebensmittel speichern</h2>
|
|
<div className="mt-4 grid gap-3">
|
|
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.brand} onChange={(event) => setForm((current) => ({ ...current, brand: event.target.value }))} placeholder="Marke optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))} placeholder="Kategorie" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<label className="flex items-center gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.availableEverywhere}
|
|
onChange={(event) => setForm((current) => ({ ...current, availableEverywhere: event.target.checked }))}
|
|
/>
|
|
Überall erhältlich
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<input value={form.kcalPer100g} onChange={(event) => setForm((current) => ({ ...current, kcalPer100g: event.target.value }))} placeholder="kcal/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.servingG} onChange={(event) => setForm((current) => ({ ...current, servingG: event.target.value }))} placeholder="Portion g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.proteinPer100g} onChange={(event) => setForm((current) => ({ ...current, proteinPer100g: event.target.value }))} placeholder="Protein/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.carbsPer100g} onChange={(event) => setForm((current) => ({ ...current, carbsPer100g: event.target.value }))} placeholder="KH/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.fatPer100g} onChange={(event) => setForm((current) => ({ ...current, fatPer100g: event.target.value }))} placeholder="Fett/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.barcode} onChange={(event) => setForm((current) => ({ ...current, barcode: event.target.value }))} placeholder="Barcode" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
</div>
|
|
<div className="rounded-3xl bg-gray-50 p-4">
|
|
<p className="text-sm font-black text-gray-950">Alltagseinheiten</p>
|
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
|
<select value={form.preferredUnit} onChange={(event) => setForm((current) => ({ ...current, preferredUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
|
|
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>Standard: {unitLabel(portionUnit)}</option>)}
|
|
</select>
|
|
<input value={form.densityGPerMl} onChange={(event) => setForm((current) => ({ ...current, densityGPerMl: event.target.value }))} placeholder="Dichte g/ml optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.portionLabel} onChange={(event) => setForm((current) => ({ ...current, portionLabel: event.target.value }))} placeholder="Portion, z. B. 1 Becher" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<select value={form.portionUnit} onChange={(event) => setForm((current) => ({ ...current, portionUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
|
|
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>{unitLabel(portionUnit)}</option>)}
|
|
</select>
|
|
<input value={form.portionAmount} onChange={(event) => setForm((current) => ({ ...current, portionAmount: event.target.value }))} placeholder="Menge, z. B. 1" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
<input value={form.portionGramsEquivalent} onChange={(event) => setForm((current) => ({ ...current, portionGramsEquivalent: event.target.value }))} placeholder="entspricht g/ml-Basis" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => saveFood(form.barcode ? "openfoodfacts" : "manual")}
|
|
disabled={saveFoodMutation.isPending}
|
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
In Katalog aufnehmen
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
|
|
{saveFoodMutation.error ? <div className="rounded-3xl bg-red-50 px-5 py-4 text-sm font-bold text-red-800">{saveFoodMutation.error.message}</div> : null}
|
|
</aside>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|