Files
Epicure/apps/web/components/recipe/recipe-form.tsx
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00

1004 lines
38 KiB
TypeScript

"use client";
import { useState, useRef, useEffect, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, GripVertical, X, Tag } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { DietaryTagPicker } from "./dietary-tag-picker";
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
import { RecipeCoverPicker } from "./recipe-cover-picker";
import { RegenerateRecipeButton } from "./regenerate-recipe-button";
import type { RegeneratedRecipe } from "@/lib/ai/features/regenerate-recipe";
type DietaryTags = {
vegan?: boolean;
vegetarian?: boolean;
glutenFree?: boolean;
dairyFree?: boolean;
nutFree?: boolean;
halal?: boolean;
kosher?: boolean;
};
type IngredientRow = {
id: string;
rawName: string;
quantity: string;
unit: string;
note: string;
};
type TimerUnit = "seconds" | "minutes" | "hours";
type StepRow = {
id: string;
instruction: string;
timerSeconds: string;
timerUnit: TimerUnit;
appliesTo: string[];
};
const TIMER_UNIT_SECONDS: Record<TimerUnit, number> = { seconds: 1, minutes: 60, hours: 3600 };
/** Picks the largest unit that divides evenly into the stored seconds, so
* editing a 90-minute braise shows "90 min" rather than "5400 sec". */
function secondsToTimerInput(totalSeconds: number): { value: string; unit: TimerUnit } {
if (totalSeconds > 0 && totalSeconds % 3600 === 0) return { value: String(totalSeconds / 3600), unit: "hours" };
if (totalSeconds > 0 && totalSeconds % 60 === 0) return { value: String(totalSeconds / 60), unit: "minutes" };
return { value: String(totalSeconds), unit: "seconds" };
}
type DishRow = {
id: string;
name: string;
description: string;
fridgeDays: string;
freezerFriendly: boolean;
freezerNote: string;
dayOfInstructions: string;
};
type RecipeFormProps = {
recipeId?: string;
defaultValues?: {
title?: string;
description?: string;
baseServings?: number;
recipeType?: "dish" | "drink";
visibility?: "private" | "unlisted" | "public" | "followers";
difficulty?: "easy" | "medium" | "hard" | null;
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: DietaryTags;
tags?: string[];
ingredients?: IngredientRow[];
steps?: StepRow[];
photos?: PhotoEntry[];
coverIcon?: string | null;
coverColor?: string | null;
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: "" };
}
function newStep(): StepRow {
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", timerUnit: "minutes", appliesTo: [] };
}
function newDish(): DishRow {
return { id: crypto.randomUUID(), name: "", description: "", fridgeDays: "3", freezerFriendly: false, freezerNote: "", dayOfInstructions: "" };
}
export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const router = useRouter();
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();
const [title, setTitle] = useState(defaultValues?.title ?? "");
const [description, setDescription] = useState(defaultValues?.description ?? "");
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
const [recipeType, setRecipeType] = useState<"dish" | "drink">(defaultValues?.recipeType ?? "dish");
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public" | "followers">(defaultValues?.visibility ?? "private");
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
const [tags, setTags] = useState<string[]>(defaultValues?.tags ?? []);
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()]
);
const [steps, setSteps] = useState<StepRow[]>(
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
);
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
const [coverIcon, setCoverIcon] = useState<string | null>(defaultValues?.coverIcon ?? null);
const [coverColor, setCoverColor] = useState<string | null>(defaultValues?.coverColor ?? null);
const [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false);
const [dishes, setDishes] = useState<DishRow[]>(
defaultValues?.dishes?.length ? defaultValues.dishes : [newDish()]
);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
const hasMountedRef = useRef(false);
// Mark the form dirty on any edit after the initial mount, so we can warn the
// user before they navigate away and silently lose their changes.
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
setDirty(true);
}, [
title,
description,
baseServings,
visibility,
difficulty,
prepMins,
cookMins,
tags,
dietaryTags,
ingredients,
steps,
photos,
isBatchCook,
dishes,
recipeType,
addNutrition,
nutrition,
]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
useEffect(() => {
if (!dirty || saving) return;
function handleBeforeUnload(e: BeforeUnloadEvent) {
e.preventDefault();
e.returnValue = "";
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [dirty, saving]);
function handleCancelClick() {
if (dirty) {
setDiscardConfirmOpen(true);
} else {
router.back();
}
}
function updateIngredient(i: number, patch: Partial<IngredientRow>) {
setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
function removeIngredient(i: number) {
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
}
// Merges an AI-regenerated draft into the in-progress form state — it
// never touches the DB directly, so the user still has to hit Save.
function applyRegenerated(recipe: RegeneratedRecipe) {
setTitle(recipe.title);
if (recipe.description !== undefined) setDescription(recipe.description);
setBaseServings(String(recipe.baseServings));
if (recipe.difficulty) setDifficulty(recipe.difficulty);
setPrepMins(recipe.prepMins !== undefined ? String(recipe.prepMins) : "");
setCookMins(recipe.cookMins !== undefined ? String(recipe.cookMins) : "");
if (recipe.dietaryTags) setDietaryTags(recipe.dietaryTags);
setIngredients(recipe.ingredients.map((ing) => ({
id: crypto.randomUUID(),
rawName: ing.rawName,
quantity: ing.quantity !== undefined ? String(ing.quantity) : "",
unit: ing.unit ?? "",
note: ing.note ?? "",
})));
setSteps(recipe.steps.map((step) => {
const timer = step.timerSeconds !== undefined ? secondsToTimerInput(step.timerSeconds) : null;
return {
id: crypto.randomUUID(),
instruction: step.instruction,
timerSeconds: timer?.value ?? "",
timerUnit: timer?.unit ?? "minutes",
appliesTo: [],
};
}));
}
function addTag(raw: string) {
const tag = raw.trim().toLowerCase().slice(0, 50);
if (!tag || tags.includes(tag) || tags.length >= 20) return;
setTags((prev) => [...prev, tag]);
setTagInput("");
}
function removeTag(tag: string) {
setTags((prev) => prev.filter((t) => t !== tag));
}
function handleTagKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
addTag(tagInput);
} else if (e.key === "Backspace" && !tagInput && tags.length > 0) {
setTags((prev) => prev.slice(0, -1));
}
}
function updateStep(i: number, patch: Partial<StepRow>) {
setSteps((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
function removeStep(i: number) {
setSteps((prev) => prev.filter((_, idx) => idx !== i));
}
function toggleStepDish(i: number, dishName: string) {
setSteps((prev) => prev.map((row, idx) => {
if (idx !== i) return row;
const has = row.appliesTo.includes(dishName);
return { ...row, appliesTo: has ? row.appliesTo.filter((n) => n !== dishName) : [...row.appliesTo, dishName] };
}));
}
function updateDish(i: number, patch: Partial<DishRow>) {
const oldName = dishes[i]?.name;
setDishes((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
// appliesTo tracks dishes by name (matches the DB's join-by-string-array
// design), so a rename must be propagated or steps silently detach.
if (patch.name !== undefined && oldName && patch.name !== oldName) {
setSteps((prev) => prev.map((row) => ({
...row,
appliesTo: row.appliesTo.map((n) => n === oldName ? patch.name! : n),
})));
}
}
function removeDish(i: number) {
const removedName = dishes[i]?.name;
setDishes((prev) => prev.filter((_, idx) => idx !== i));
if (removedName) {
setSteps((prev) => prev.map((row) => ({ ...row, appliesTo: row.appliesTo.filter((n) => n !== removedName) })));
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
toast.error(t("titleRequired"));
return;
}
const filteredIngredients = ingredients
.filter((ing) => ing.rawName.trim())
.map((ing, i) => ({
rawName: ing.rawName.trim(),
quantity: ing.quantity.trim() || undefined,
unit: ing.unit.trim() || undefined,
note: ing.note.trim() || undefined,
order: i,
}));
if (filteredIngredients.length === 0) {
toast.error(t("ingredientsRequired"));
return;
}
const filteredDishes = isBatchCook
? dishes
.filter((d) => d.name.trim())
.map((d) => ({
name: d.name.trim(),
description: d.description.trim() || undefined,
fridgeDays: parseInt(d.fridgeDays) || 3,
freezerFriendly: d.freezerFriendly,
freezerNote: d.freezerNote.trim() || undefined,
dayOfInstructions: d.dayOfInstructions.trim(),
}))
: [];
if (isBatchCook && filteredDishes.length === 0) {
toast.error(t("dishesRequired"));
return;
}
if (isBatchCook && filteredDishes.some((d) => !d.dayOfInstructions)) {
toast.error(t("dayOfInstructionsRequired"));
return;
}
const dishNames = new Set(filteredDishes.map((d) => d.name));
const filteredSteps = steps
.filter((s) => s.instruction.trim())
.map((s, i) => ({
instruction: s.instruction.trim(),
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) * TIMER_UNIT_SECONDS[s.timerUnit] : undefined,
order: i,
appliesTo: isBatchCook ? s.appliesTo.filter((n) => dishNames.has(n)) : [],
}));
if (filteredSteps.length === 0) {
toast.error(t("stepsRequired"));
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
description: description.trim() || undefined,
baseServings: parseInt(baseServings) || 4,
recipeType,
visibility,
difficulty: difficulty || undefined,
prepMins: prepMins ? parseInt(prepMins) : undefined,
cookMins: cookMins ? parseInt(cookMins) : undefined,
tags,
dietaryTags,
ingredients: filteredIngredients,
steps: filteredSteps,
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover, sizeMb: p.sizeMb })),
coverIcon,
coverColor,
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";
const method = isEdit ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? t("saveError"));
return;
}
const saved = await res.json() as { id: string };
toast.success(isEdit ? t("updateSuccess") : t("createSuccess"));
setDirty(false);
router.push(`/recipes/${saved.id}`);
router.refresh();
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8 max-w-3xl pb-20">
{/* Basic info */}
<div className="space-y-4">
{isEdit && (
<div className="flex justify-end">
<RegenerateRecipeButton
current={{
title,
description,
baseServings: Number(baseServings) || 1,
difficulty,
ingredients: ingredients.map((ing) => ({ rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit })),
steps: steps.map((step) => ({ instruction: step.instruction })),
}}
onRegenerated={applyRegenerated}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="title">{t("titleLabel")}</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("titlePlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">{t("descriptionLabel")}</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("descriptionPlaceholder")}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="recipeType">{t("recipeType")}</Label>
<select
id="recipeType"
value={recipeType}
onChange={(e) => setRecipeType(e.target.value as "dish" | "drink")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="dish">{t("recipeTypeDish")}</option>
<option value="drink">{t("recipeTypeDrink")}</option>
</select>
</div>
<div className={cn("grid grid-cols-2 gap-4", recipeType === "dish" && "md:grid-cols-4")}>
<div className="space-y-2">
<Label htmlFor="servings">{recipeType === "drink" ? t("servingsDrink") : t("servings")}</Label>
<Input
id="servings"
type="number"
min={1}
max={100}
value={baseServings}
onChange={(e) => setBaseServings(e.target.value)}
/>
</div>
{recipeType === "dish" && (
<>
<div className="space-y-2">
<Label htmlFor="prep">{t("prepMins")}</Label>
<Input
id="prep"
type="number"
min={0}
value={prepMins}
onChange={(e) => setPrepMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cook">{t("cookMins")}</Label>
<Input
id="cook"
type="number"
min={0}
value={cookMins}
onChange={(e) => setCookMins(e.target.value)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label htmlFor="difficulty">{t("difficulty")}</Label>
<select
id="difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value as "easy" | "medium" | "hard" | "")}
className="flex h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value=""></option>
<option value="easy">{t_recipe("difficulty.easy")}</option>
<option value="medium">{t_recipe("difficulty.medium")}</option>
<option value="hard">{t_recipe("difficulty.hard")}</option>
</select>
</div>
</>
)}
</div>
<div className="space-y-2">
<Label htmlFor="visibility">{t_recipe("visibilityMenuLabel")}</Label>
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "unlisted" | "public" | "followers")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="private">{t_recipe("visibility.private")}</option>
<option value="followers">{t_recipe("visibility.followers")}</option>
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
<option value="public">{t_recipe("visibility.public")}</option>
</select>
</div>
{/* Tags */}
<div className="space-y-2">
<Label>{t("tags")}</Label>
<div
className="flex flex-wrap gap-1.5 min-h-[36px] rounded-lg border border-input bg-transparent px-2.5 py-1.5 cursor-text focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50"
onClick={() => tagInputRef.current?.focus()}
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-muted text-muted-foreground"
>
<Tag className="h-2.5 w-2.5" />
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
className="hover:text-foreground transition-colors"
aria-label={t("removeTagAriaLabel", { tag })}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
{tags.length < 20 && (
<input
ref={tagInputRef}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (tagInput.trim()) addTag(tagInput); }}
placeholder={tags.length === 0 ? t("tagsPlaceholder") : ""}
className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
)}
</div>
<p className="text-xs text-muted-foreground">{t("tagsHelp")}</p>
</div>
</div>
<Separator />
{/* Dietary tags */}
<div className="space-y-2">
<Label>{t("dietaryTags")}</Label>
<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 />
{/* Batch cooking */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<Switch id="batch-cook" checked={isBatchCook} onCheckedChange={setIsBatchCook} />
<Label htmlFor="batch-cook" className="cursor-pointer">{t("batchCookToggle")}</Label>
</div>
<p className="text-xs text-muted-foreground">{t("batchCookToggleHelp")}</p>
</div>
</>
)}
<Separator />
{/* Photos */}
<div className="space-y-2">
<Label>{t("photos")}</Label>
<PhotoUploader recipeId={id} photos={photos} onChange={setPhotos} />
</div>
{/* Cover placeholder (used when there's no photo above) */}
<div className="space-y-2">
<Label>{t("cover")}</Label>
<RecipeCoverPicker
recipeType={recipeType}
color={coverColor}
icon={coverIcon}
onChange={({ color, icon }) => {
setCoverColor(color);
setCoverIcon(icon);
}}
/>
</div>
<Separator />
{/* Ingredients */}
<div className="space-y-3">
<Label>{t("ingredients")}</Label>
<div className="space-y-2">
{ingredients.map((ing, i) => (
<div key={ing.id} className="flex flex-col sm:flex-row gap-2 sm:items-start">
<div className="flex gap-2 items-start">
<GripVertical className="h-4 w-4 text-muted-foreground mt-2 cursor-grab shrink-0" />
<Input
value={ing.quantity}
onChange={(e) => updateIngredient(i, { quantity: e.target.value })}
placeholder={t("amount")}
className="w-16 shrink-0"
/>
<Input
value={ing.unit}
onChange={(e) => updateIngredient(i, { unit: e.target.value })}
placeholder={t("unit")}
className="w-16 sm:w-24 shrink-0"
/>
<Input
value={ing.rawName}
onChange={(e) => updateIngredient(i, { rawName: e.target.value })}
placeholder={t("ingredient")}
className="flex-1 min-w-0"
/>
</div>
<div className="flex gap-2 items-start pl-6 sm:pl-0">
<Input
value={ing.note}
onChange={(e) => updateIngredient(i, { note: e.target.value })}
placeholder={t("note")}
className="flex-1 sm:w-48 sm:shrink-0"
/>
<button
type="button"
onClick={() => removeIngredient(i)}
disabled={ingredients.length === 1}
className="mt-2 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30 shrink-0"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIngredients((p) => [...p, newIngredient()])}
>
<Plus className="h-4 w-4" />
{t("addIngredient")}
</Button>
</div>
<Separator />
{/* Batch-cook dishes */}
{isBatchCook && (
<>
<div className="space-y-3">
<Label>{t("dishes")}</Label>
<div className="space-y-4">
{dishes.map((dish, i) => (
<div key={dish.id} className="rounded-lg border p-3 space-y-2">
<div className="flex gap-2 items-start">
<Input
value={dish.name}
onChange={(e) => updateDish(i, { name: e.target.value })}
placeholder={t("dishName")}
className="flex-1 min-w-0 font-medium"
/>
<button
type="button"
onClick={() => removeDish(i)}
disabled={dishes.length === 1}
className="mt-1.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<Textarea
value={dish.description}
onChange={(e) => updateDish(i, { description: e.target.value })}
placeholder={t("dishDescription")}
rows={2}
/>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 items-end">
<div className="space-y-1">
<Label htmlFor={`fridge-${dish.id}`} className="text-xs text-muted-foreground">{t("fridgeDays")}</Label>
<Input
id={`fridge-${dish.id}`}
type="number"
min={1}
max={14}
value={dish.fridgeDays}
onChange={(e) => updateDish(i, { fridgeDays: e.target.value })}
/>
</div>
<div className="flex items-center gap-2 pb-1.5">
<Switch
id={`freezer-${dish.id}`}
checked={dish.freezerFriendly}
onCheckedChange={(v) => updateDish(i, { freezerFriendly: v })}
/>
<Label htmlFor={`freezer-${dish.id}`} className="cursor-pointer text-xs">{t("freezerFriendly")}</Label>
</div>
{dish.freezerFriendly && (
<div className="space-y-1 col-span-2">
<Label htmlFor={`freezer-note-${dish.id}`} className="text-xs text-muted-foreground">{t("freezerNote")}</Label>
<Input
id={`freezer-note-${dish.id}`}
value={dish.freezerNote}
onChange={(e) => updateDish(i, { freezerNote: e.target.value })}
/>
</div>
)}
</div>
<div className="space-y-1">
<Label htmlFor={`dayof-${dish.id}`} className="text-xs text-muted-foreground">{t("dayOfInstructions")}</Label>
<Textarea
id={`dayof-${dish.id}`}
value={dish.dayOfInstructions}
onChange={(e) => updateDish(i, { dayOfInstructions: e.target.value })}
rows={2}
/>
</div>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDishes((p) => [...p, newDish()])}
>
<Plus className="h-4 w-4" />
{t("addDish")}
</Button>
</div>
<Separator />
</>
)}
{/* Steps */}
<div className="space-y-3">
<Label>{t("steps")}</Label>
<div className="space-y-3">
{steps.map((step, i) => (
<div key={step.id} className="space-y-1.5">
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
<div className="flex gap-3 flex-1 min-w-0">
<div className="mt-2.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-bold text-muted-foreground">
{i + 1}
</div>
<Textarea
value={step.instruction}
onChange={(e) => updateStep(i, { instruction: e.target.value })}
placeholder={t("stepPlaceholder", { n: i + 1 })}
rows={2}
className="flex-1 min-w-0"
/>
</div>
<div className="flex gap-2 items-start pl-9 sm:pl-0">
<Input
value={step.timerSeconds}
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
placeholder={t("timerSeconds")}
type="number"
min={0}
className="w-20 shrink-0"
/>
<select
value={step.timerUnit}
onChange={(e) => updateStep(i, { timerUnit: e.target.value as TimerUnit })}
aria-label={t("timerUnitAriaLabel")}
className="h-8 shrink-0 rounded-lg border border-input bg-transparent px-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="seconds">{t("timerUnit.seconds")}</option>
<option value="minutes">{t("timerUnit.minutes")}</option>
<option value="hours">{t("timerUnit.hours")}</option>
</select>
<button
type="button"
onClick={() => removeStep(i)}
disabled={steps.length === 1}
className="mt-2 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30 shrink-0"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{isBatchCook && dishes.some((d) => d.name.trim()) && (
<div className="flex flex-wrap gap-1.5 pl-9">
<span className="text-xs text-muted-foreground self-center">{t("appliesTo")}:</span>
{dishes.filter((d) => d.name.trim()).map((d) => (
<button
key={d.id}
type="button"
onClick={() => toggleStepDish(i, d.name.trim())}
className={cn(
"px-2 py-0.5 rounded-full text-xs border transition-colors",
step.appliesTo.includes(d.name.trim())
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-input hover:border-muted-foreground/50"
)}
>
{d.name.trim()}
</button>
))}
{step.appliesTo.length === 0 && (
<span className="text-xs text-muted-foreground italic self-center">{t("sharedPrep")}</span>
)}
</div>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setSteps((p) => [...p, newStep()])}
>
<Plus className="h-4 w-4" />
{t("addStep")}
</Button>
</div>
<Separator />
<div className="flex gap-3">
<Button type="submit" disabled={saving}>
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
</Button>
<Button type="button" variant="outline" onClick={handleCancelClick}>
{t_common("cancel")}
</Button>
</div>
{/* Floating save bar — the form above can get long, this stays reachable without scrolling back down */}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 rounded-full border bg-popover p-1.5 shadow-lg">
<Button type="submit" size="sm" className="rounded-full" disabled={saving}>
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
</Button>
<Button type="button" size="sm" variant="ghost" className="rounded-full" onClick={handleCancelClick}>
{t_common("cancel")}
</Button>
</div>
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("discardChangesTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("discardChangesDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("keepEditing")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDiscardConfirmOpen(false);
setDirty(false);
router.back();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("discardChanges")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
);
}