feat: manual nutrition entry on recipe editor

Lets authors enter nutrition values by hand instead of relying only on
the AI estimate; the detail-page panel now shows whether data was
manually entered or AI-estimated and offers to switch between them.

v0.35.0
This commit is contained in:
Arnaud
2026-07-14 15:55:42 +02:00
parent bd3d8c88f0
commit 1577b8de01
18 changed files with 5275 additions and 15 deletions
@@ -19,11 +19,13 @@ type NutritionData = {
interface NutritionPanelProps {
recipeId: string;
initialData?: NutritionData | null;
initialManual?: boolean;
}
export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
export function NutritionPanel({ recipeId, initialData, initialManual }: NutritionPanelProps) {
const t = useTranslations("nutritionPanel");
const [nutrition, setNutrition] = useState<NutritionData | null>(initialData ?? null);
const [manual, setManual] = useState(!!initialManual);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -39,6 +41,7 @@ export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
}
const data = await res.json() as { nutrition: NutritionData };
setNutrition(data.nutrition);
setManual(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("error"));
} finally {
@@ -69,9 +72,12 @@ export function NutritionPanel({ recipeId, initialData }: NutritionPanelProps) {
disabled={loading}
className="text-xs text-muted-foreground"
>
{loading ? t("estimating") : t("reEstimateButton")}
{loading ? t("estimating") : manual ? t("estimateInsteadButton") : t("reEstimateButton")}
</Button>
</div>
{manual && !loading && (
<p className="text-xs text-muted-foreground">{t("manualBadge")}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</CardHeader>
{nutrition && (
+120
View File
@@ -78,9 +78,28 @@ type RecipeFormProps = {
photos?: PhotoEntry[];
isBatchCook?: boolean;
dishes?: DishRow[];
nutrition?: {
calories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
sodiumMg: number;
} | null;
};
};
type NutritionValues = {
calories: string;
proteinG: string;
carbsG: string;
fatG: string;
fiberG: string;
sodiumMg: string;
};
const EMPTY_NUTRITION: NutritionValues = { calories: "", proteinG: "", carbsG: "", fatG: "", fiberG: "", sodiumMg: "" };
function newIngredient(): IngredientRow {
return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" };
}
@@ -98,6 +117,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const t = useTranslations("recipeForm");
const t_recipe = useTranslations("recipe");
const t_common = useTranslations("common");
const t_nutrition = useTranslations("nutritionPanel");
const isEdit = !!recipeId;
const id = recipeId ?? crypto.randomUUID();
@@ -113,6 +133,19 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const [tagInput, setTagInput] = useState("");
const tagInputRef = useRef<HTMLInputElement>(null);
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
const [addNutrition, setAddNutrition] = useState(!!defaultValues?.nutrition);
const [nutrition, setNutrition] = useState<NutritionValues>(
defaultValues?.nutrition
? {
calories: String(defaultValues.nutrition.calories),
proteinG: String(defaultValues.nutrition.proteinG),
carbsG: String(defaultValues.nutrition.carbsG),
fatG: String(defaultValues.nutrition.fatG),
fiberG: String(defaultValues.nutrition.fiberG),
sodiumMg: String(defaultValues.nutrition.sodiumMg),
}
: EMPTY_NUTRITION
);
const [ingredients, setIngredients] = useState<IngredientRow[]>(
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
);
@@ -153,6 +186,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
isBatchCook,
dishes,
recipeType,
addNutrition,
nutrition,
]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
@@ -316,6 +351,16 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
isBatchCook,
dishes: filteredDishes,
nutrition: addNutrition
? {
calories: Number(nutrition.calories) || 0,
proteinG: Number(nutrition.proteinG) || 0,
carbsG: Number(nutrition.carbsG) || 0,
fatG: Number(nutrition.fatG) || 0,
fiberG: Number(nutrition.fiberG) || 0,
sodiumMg: Number(nutrition.sodiumMg) || 0,
}
: isEdit ? null : undefined,
};
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
@@ -498,6 +543,81 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<DietaryTagPicker value={dietaryTags} onChange={setDietaryTags} />
</div>
<Separator />
{/* Manual nutrition */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch id="add-nutrition" checked={addNutrition} onCheckedChange={setAddNutrition} />
<Label htmlFor="add-nutrition" className="cursor-pointer">{t("nutritionToggle")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("nutritionToggleHelp")}</p>
{addNutrition && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="nutrition-calories">{t("calories")}</Label>
<Input
id="nutrition-calories"
type="number"
min={0}
value={nutrition.calories}
onChange={(e) => setNutrition((prev) => ({ ...prev, calories: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nutrition-protein">{t_nutrition("protein")} (g)</Label>
<Input
id="nutrition-protein"
type="number"
min={0}
value={nutrition.proteinG}
onChange={(e) => setNutrition((prev) => ({ ...prev, proteinG: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nutrition-carbs">{t_nutrition("carbs")} (g)</Label>
<Input
id="nutrition-carbs"
type="number"
min={0}
value={nutrition.carbsG}
onChange={(e) => setNutrition((prev) => ({ ...prev, carbsG: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nutrition-fat">{t_nutrition("fat")} (g)</Label>
<Input
id="nutrition-fat"
type="number"
min={0}
value={nutrition.fatG}
onChange={(e) => setNutrition((prev) => ({ ...prev, fatG: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nutrition-fiber">{t_nutrition("fiber")} (g)</Label>
<Input
id="nutrition-fiber"
type="number"
min={0}
value={nutrition.fiberG}
onChange={(e) => setNutrition((prev) => ({ ...prev, fiberG: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="nutrition-sodium">{t_nutrition("sodium")} (mg)</Label>
<Input
id="nutrition-sodium"
type="number"
min={0}
value={nutrition.sodiumMg}
onChange={(e) => setNutrition((prev) => ({ ...prev, sodiumMg: e.target.value }))}
/>
</div>
</div>
)}
</div>
{recipeType === "dish" && (
<>
<Separator />