2f18462548
Step timer input was seconds-only, no unit — a 90-minute braise meant typing 5400. Added a seconds/minutes/hours <select> next to the input; StepRow gets a timerUnit field, converted to seconds at submit. Editing an existing recipe (and the AI-regenerate flow) picks the largest unit that divides evenly into the stored seconds so it displays naturally instead of always falling back to raw seconds. Ingredient list (serving-scaler.tsx): the quantity column used min-w-[3rem] on a flex child, which is only a *minimum* — any row whose formatted quantity text (e.g. an appended "(~2 tbsp)" conversion) exceeded that width pushed just that row's ingredient name further right, breaking alignment across the list. Switched the list to a CSS grid with `display: contents` on each <li>, so the quantity column's width is shared across every row instead of sized per-row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1004 lines
38 KiB
TypeScript
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 })),
|
|
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>
|
|
);
|
|
}
|