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:
@@ -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 && (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user