f6975e98a9
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>
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, 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>
|
|
);
|
|
}
|