"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"; 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 StepRow = { id: string; instruction: string; timerSeconds: string; appliesTo: string[]; }; 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"; difficulty?: "easy" | "medium" | "hard" | null; prepMins?: number | null; cookMins?: number | null; dietaryTags?: DietaryTags; tags?: string[]; ingredients?: IngredientRow[]; steps?: StepRow[]; photos?: PhotoEntry[]; isBatchCook?: boolean; dishes?: DishRow[]; }; }; function newIngredient(): IngredientRow { return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" }; } function newStep(): StepRow { return { id: crypto.randomUUID(), instruction: "", timerSeconds: "", 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 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">(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(defaultValues?.tags ?? []); const [tagInput, setTagInput] = useState(""); const tagInputRef = useRef(null); const [dietaryTags, setDietaryTags] = useState(defaultValues?.dietaryTags ?? {}); const [ingredients, setIngredients] = useState( defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()] ); const [steps, setSteps] = useState( defaultValues?.steps?.length ? defaultValues.steps : [newStep()] ); const [photos, setPhotos] = useState(defaultValues?.photos ?? []); const [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false); const [dishes, setDishes] = useState( 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, ]); // 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) { setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row)); } function removeIngredient(i: number) { setIngredients((prev) => prev.filter((_, idx) => idx !== i)); } 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) { 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) { 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) { 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) : 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 })), isBatchCook, dishes: filteredDishes, }; 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 (
{/* Basic info */}
setTitle(e.target.value)} placeholder={t("titlePlaceholder")} required />