* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -0,0 +1,306 @@
import { useMemo } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { fetchIngredientCatalogItems } from "@/api/ingredient-catalog.api";
import { MealCategory } from "@/types/recipe.types";
import { useMealCategoryLabels } from "@/i18n/useMealCategoryLabel";
import {
countUnlinkedMacroIngredients,
estimateMacrosFromCatalog,
isUnlinkedMacroIngredient,
} from "@/utils/recipe-macros.util";
import { ingredientDisplayName } from "@/utils/ingredient-catalog.util";
import type { CreateRecipeInput } from "@/types/recipe-management.types";
const MEAL_CATEGORIES = [
MealCategory.Breakfast,
MealCategory.SecondBreakfast,
MealCategory.Lunch,
MealCategory.Dinner,
] as const;
const EMPTY_DEFAULT: CreateRecipeInput = {
name: "",
mealCategory: MealCategory.Breakfast,
calories: null,
protein: null,
fat: null,
carbs: null,
prepTimeMinutes: null,
ingredients: [{ name: "", amountGrams: null, unit: "", sortOrder: 0, catalogItemId: null }],
steps: [{ stepNumber: 1, description: "" }],
};
interface RecipeFormProps {
onSubmit: (data: CreateRecipeInput) => Promise<void>;
pending?: boolean;
error?: string | null;
initialValues?: CreateRecipeInput;
submitLabel?: string;
resetOnSubmit?: boolean;
}
export function RecipeForm({
onSubmit,
pending,
error,
initialValues,
submitLabel,
resetOnSubmit = true,
}: RecipeFormProps) {
const { t, i18n } = useTranslation();
const categoryLabels = useMealCategoryLabels();
const catalogQuery = useQuery({
queryKey: ["ingredient-catalog", "all"],
queryFn: () => fetchIngredientCatalogItems(),
});
const recipeSchema = useMemo(
() =>
z.object({
name: z.string().min(1, t("validation.recipeNameRequired")).max(255),
mealCategory: z.nativeEnum(MealCategory),
calories: z.coerce.number().int().min(0).nullable().optional(),
protein: z.coerce.number().min(0).nullable().optional(),
fat: z.coerce.number().min(0).nullable().optional(),
carbs: z.coerce.number().min(0).nullable().optional(),
prepTimeMinutes: z.coerce.number().int().min(0).nullable().optional(),
ingredients: z
.array(
z.object({
name: z.string().min(1, t("validation.ingredientNameRequired")),
amountGrams: z.coerce.number().min(0).nullable().optional(),
unit: z.string().optional(),
sortOrder: z.coerce.number().int().min(0).default(0),
catalogItemId: z.coerce.number().nullable().optional(),
}),
)
.default([]),
steps: z
.array(
z.object({
stepNumber: z.coerce.number().int().min(1, t("validation.stepNumberMin")),
description: z.string().min(1, t("validation.stepDescriptionRequired")),
}),
)
.min(1, t("validation.stepsMin")),
}),
[t],
);
type RecipeFormValues = z.infer<typeof recipeSchema>;
const formDefaults = initialValues ?? EMPTY_DEFAULT;
const {
register,
control,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<RecipeFormValues>({
resolver: zodResolver(recipeSchema),
defaultValues: formDefaults,
});
const {
fields: ingredientFields,
append: appendIngredient,
remove: removeIngredient,
} = useFieldArray({ control, name: "ingredients" });
const {
fields: stepFields,
append: appendStep,
remove: removeStep,
} = useFieldArray({ control, name: "steps" });
const watchedIngredients = watch("ingredients");
const catalogItems = catalogQuery.data ?? [];
const estimatedMacros = useMemo(
() => estimateMacrosFromCatalog(watchedIngredients ?? [], catalogItems),
[watchedIngredients, catalogItems],
);
const unlinkedCount = useMemo(
() => countUnlinkedMacroIngredients(watchedIngredients ?? []),
[watchedIngredients],
);
const submit = handleSubmit(async (values) => {
await onSubmit({
name: values.name,
mealCategory: values.mealCategory,
calories: values.calories ?? null,
protein: values.protein ?? null,
fat: values.fat ?? null,
carbs: values.carbs ?? null,
prepTimeMinutes: values.prepTimeMinutes ?? null,
ingredients: values.ingredients.map((i, index) => ({
name: i.name,
amountGrams: i.amountGrams ?? null,
unit: i.unit ?? "",
sortOrder: i.sortOrder ?? index,
catalogItemId: i.catalogItemId ?? null,
})),
steps: values.steps,
});
if (resetOnSubmit) reset(EMPTY_DEFAULT);
});
return (
<form onSubmit={submit} className="space-y-8" noValidate>
{error && (
<div role="alert" className="rounded-xl bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
)}
<section className="card space-y-4 p-6">
<h2 className="text-lg font-bold">{t("manage.basicInfo")}</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1.5 block text-sm font-medium">{t("manage.recipeName")}</label>
<input className="input" {...register("name")} placeholder={t("manage.recipeNamePlaceholder")} />
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium">{t("common.category")}</label>
<div className="relative">
<select className="select" {...register("mealCategory", { valueAsNumber: true })}>
{MEAL_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{categoryLabels[cat]}
</option>
))}
</select>
<ChevronDown
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400"
aria-hidden="true"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium">{t("manage.prepTimeMin")}</label>
<input type="number" className="input" {...register("prepTimeMinutes")} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium">{t("manage.caloriesKcal")}</label>
<input type="number" className="input" {...register("calories")} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium">{t("manage.proteinG")}</label>
<input type="number" step="0.1" className="input" {...register("protein")} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium">{t("manage.fatG")}</label>
<input type="number" step="0.1" className="input" {...register("fat")} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium">{t("manage.carbsG")}</label>
<input type="number" step="0.1" className="input" {...register("carbs")} />
</div>
</div>
{estimatedMacros ? (
<p className="text-sm text-slate-500">
{t("manage.estimatedFromCatalog")}: {estimatedMacros.calories} kcal · P{" "}
{estimatedMacros.protein.toFixed(1)}g · F {estimatedMacros.fat.toFixed(1)}g · C{" "}
{estimatedMacros.carbs.toFixed(1)}g
</p>
) : null}
{unlinkedCount > 0 ? (
<p className="text-sm text-amber-700 dark:text-amber-300">
{t("manage.unlinkedIngredientsWarning", { count: unlinkedCount })}
</p>
) : null}
</section>
<section className="card space-y-4 p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">{t("recipes.ingredients")}</h2>
<button
type="button"
className="btn-secondary"
onClick={() =>
appendIngredient({
name: "",
amountGrams: null,
unit: "",
sortOrder: ingredientFields.length,
catalogItemId: null,
})
}
>
<Plus className="h-4 w-4" /> {t("common.add")}
</button>
</div>
<div className="space-y-3">
{ingredientFields.map((field, index) => {
const ing = watchedIngredients?.[index];
const unlinked = ing ? isUnlinkedMacroIngredient(ing) : false;
return (
<div
key={field.id}
className={`grid gap-2 sm:grid-cols-[1fr_100px_100px_1fr_auto] rounded-lg p-1 ${
unlinked ? "ring-1 ring-amber-400/70 bg-amber-50/50 dark:bg-amber-950/20" : ""
}`}
>
<input className="input" placeholder={t("manage.ingredientName")} {...register(`ingredients.${index}.name`)} />
<input type="number" step="0.1" className="input" placeholder={t("manage.ingredientGrams")} {...register(`ingredients.${index}.amountGrams`)} />
<input className="input" placeholder={t("manage.ingredientUnit")} {...register(`ingredients.${index}.unit`)} />
<select className="select" {...register(`ingredients.${index}.catalogItemId`, { setValueAs: (v) => (v === "" ? null : Number(v)) })}>
<option value="">{t("manage.catalogLink")}</option>
{catalogItems.map((item) => (
<option key={item.id} value={item.id}>
{ingredientDisplayName(item, i18n.language)}
</option>
))}
</select>
<button type="button" className="btn-secondary !px-2.5" onClick={() => removeIngredient(index)} aria-label={t("manage.removeIngredient")}>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div>
</section>
<section className="card space-y-4 p-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">{t("manage.preparationSteps")}</h2>
<button
type="button"
className="btn-secondary"
onClick={() => appendStep({ stepNumber: stepFields.length + 1, description: "" })}
>
<Plus className="h-4 w-4" /> {t("manage.addStep")}
</button>
</div>
{errors.steps?.message && <p className="text-sm text-red-600">{errors.steps.message}</p>}
<div className="space-y-3">
{stepFields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input type="number" className="input w-20 shrink-0" {...register(`steps.${index}.stepNumber`)} />
<input className="input flex-1" placeholder={t("manage.stepPlaceholder")} {...register(`steps.${index}.description`)} />
<button type="button" className="btn-secondary !px-2.5" onClick={() => removeStep(index)} aria-label={t("manage.removeStep")}>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</section>
<button type="submit" disabled={pending} className="btn-primary">
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{pending ? t("manage.saving") : (submitLabel ?? t("manage.saveRecipe"))}
</button>
</form>
);
}