"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 { 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; }; type RecipeFormProps = { recipeId?: string; defaultValues?: { title?: string; description?: string; baseServings?: number; 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[]; }; }; function newIngredient(): IngredientRow { return { id: crypto.randomUUID(), rawName: "", quantity: "", unit: "", note: "" }; } function newStep(): StepRow { return { id: crypto.randomUUID(), instruction: "", timerSeconds: "" }; } 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 [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 [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, ]); // 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)); } 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 filteredSteps = steps .filter((s) => s.instruction.trim()) .map((s, i) => ({ instruction: s.instruction.trim(), timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined, order: i, })); 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, 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 })), }; 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 />