feat: manually create and edit batch-cook recipes

recipe-form.tsx previously had no batch-cook awareness at all — editing an
AI-generated batch-cook recipe and saving silently corrupted it (steps lost
their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned,
since the create/update API schemas and edit-page query never touched them).

Adds a "batch-cook recipe" toggle to the form, an editable dish list (name,
description, fridge days, freezer-friendly/note, day-of instructions), and
per-step dish tagging (click a dish chip to mark which dish(es) that step
advances; empty = shared prep). Renaming a dish propagates to any step
still tagged with the old name instead of orphaning it. Wired through
Create/Update API schemas and the edit page's query/payload.

Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
This commit is contained in:
Arnaud
2026-07-12 14:45:24 +02:00
parent 20e4ce3597
commit 61014a3224
6 changed files with 341 additions and 32 deletions
@@ -22,6 +22,7 @@ export default async function EditRecipePage({ params }: Params) {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
photos: { orderBy: (t, { asc }) => asc(t.order) },
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
},
});
@@ -48,12 +49,23 @@ export default async function EditRecipePage({ params }: Params) {
id: step.id,
instruction: step.instruction,
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
appliesTo: step.appliesTo ?? [],
})),
photos: recipe.photos.map((photo) => ({
key: photo.storageKey,
isCover: photo.isCover,
preview: getPublicUrl(photo.storageKey),
})),
isBatchCook: recipe.isBatchCook,
dishes: recipe.batchDishes.map((dish) => ({
id: dish.id,
name: dish.name,
description: dish.description ?? "",
fridgeDays: String(dish.fridgeDays),
freezerFriendly: dish.freezerFriendly,
freezerNote: dish.freezerNote ?? "",
dayOfInstructions: dish.dayOfInstructions,
})),
};
return (
+39 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeSnapshots, ratings } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes, recipeSnapshots, ratings } from "@epicure/db";
import { eq, and, max, isNotNull } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
@@ -40,11 +40,21 @@ const UpdateRecipeSchema = z.object({
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
})).max(100).optional(),
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).optional(),
isBatchCook: z.boolean().optional(),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
fridgeDays: z.number().int().min(1).max(14),
freezerFriendly: z.boolean().default(false),
freezerNote: z.string().max(300).optional(),
dayOfInstructions: z.string().min(1).max(1000),
})).max(10).optional(),
});
type Params = { params: Promise<{ id: string }> };
@@ -52,7 +62,12 @@ type Params = { params: Promise<{ id: string }> };
async function getOwnedRecipe(recipeId: string, userId: string) {
return db.query.recipes.findFirst({
where: and(eq(recipes.id, recipeId), eq(recipes.authorId, userId)),
with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: true },
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
photos: true,
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
},
});
}
@@ -78,6 +93,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
photos: true,
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -136,6 +152,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
if (data.tags !== undefined) updates.tags = data.tags;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
@@ -166,6 +183,26 @@ export async function PUT(req: NextRequest, { params }: Params) {
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: step.order ?? i,
appliesTo: step.appliesTo,
}))
);
}
}
if (data.dishes !== undefined) {
await tx.delete(recipeBatchDishes).where(eq(recipeBatchDishes.recipeId, id));
if (data.dishes.length > 0) {
await tx.insert(recipeBatchDishes).values(
data.dishes.map((dish, i) => ({
id: crypto.randomUUID(),
recipeId: id,
name: dish.name,
description: dish.description,
order: i,
fridgeDays: dish.fridgeDays,
freezerFriendly: dish.freezerFriendly,
freezerNote: dish.freezerNote,
dayOfInstructions: dish.dayOfInstructions,
}))
);
}
+29 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
@@ -42,11 +42,21 @@ const CreateRecipeSchema = z.object({
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
})).max(100).default([]),
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).default([]),
isBatchCook: z.boolean().default(false),
dishes: z.array(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
fridgeDays: z.number().int().min(1).max(14),
freezerFriendly: z.boolean().default(false),
freezerNote: z.string().max(300).optional(),
dayOfInstructions: z.string().min(1).max(1000),
})).max(10).default([]),
});
export async function GET(req: NextRequest) {
@@ -110,6 +120,7 @@ export async function POST(req: NextRequest) {
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
language: data.language,
isBatchCook: data.isBatchCook,
createdAt: now,
updatedAt: now,
});
@@ -136,6 +147,7 @@ export async function POST(req: NextRequest) {
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: step.order ?? i,
appliesTo: step.appliesTo,
}))
);
}
@@ -151,6 +163,22 @@ export async function POST(req: NextRequest) {
}))
);
}
if (data.dishes.length > 0) {
await tx.insert(recipeBatchDishes).values(
data.dishes.map((dish, i) => ({
id: crypto.randomUUID(),
recipeId: id,
name: dish.name,
description: dish.description,
order: i,
fridgeDays: dish.fridgeDays,
freezerFriendly: dish.freezerFriendly,
freezerNote: dish.freezerNote,
dayOfInstructions: dish.dayOfInstructions,
}))
);
}
});
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
+231 -27
View File
@@ -10,6 +10,8 @@ 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,
@@ -45,6 +47,17 @@ 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 = {
@@ -62,6 +75,8 @@ type RecipeFormProps = {
ingredients?: IngredientRow[];
steps?: StepRow[];
photos?: PhotoEntry[];
isBatchCook?: boolean;
dishes?: DishRow[];
};
};
@@ -70,7 +85,11 @@ function newIngredient(): IngredientRow {
}
function newStep(): StepRow {
return { id: crypto.randomUUID(), instruction: "", timerSeconds: "" };
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) {
@@ -99,6 +118,10 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
defaultValues?.steps?.length ? defaultValues.steps : [newStep()]
);
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
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);
@@ -125,6 +148,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
ingredients,
steps,
photos,
isBatchCook,
dishes,
]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
@@ -182,6 +207,35 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
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()) {
@@ -204,12 +258,36 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
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) {
@@ -232,6 +310,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
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";
@@ -399,6 +479,17 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<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>
@@ -462,38 +553,151 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<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="flex gap-3 items-start">
<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 key={step.id} className="space-y-1.5">
<div className="flex gap-3 items-start">
<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"
/>
<Input
value={step.timerSeconds}
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
placeholder={t("timerSeconds")}
type="number"
min={0}
className="w-28 shrink-0"
/>
<button
type="button"
onClick={() => removeStep(i)}
disabled={steps.length === 1}
className="mt-2.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
</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"
/>
<Input
value={step.timerSeconds}
onChange={(e) => updateStep(i, { timerSeconds: e.target.value })}
placeholder={t("timerSeconds")}
type="number"
min={0}
className="w-28 shrink-0"
/>
<button
type="button"
onClick={() => removeStep(i)}
disabled={steps.length === 1}
className="mt-2.5 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-30"
>
<Trash2 className="h-4 w-4" />
</button>
{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>
+15 -1
View File
@@ -660,7 +660,21 @@
"discardChangesTitle": "Discard changes?",
"discardChangesDescription": "You have unsaved changes. If you leave now, they will be lost.",
"keepEditing": "Keep editing",
"discardChanges": "Discard changes"
"discardChanges": "Discard changes",
"batchCookToggle": "This is a batch-cook recipe",
"batchCookToggleHelp": "Split this recipe into several dishes cooked together, each with its own storage and reheat guidance.",
"dishes": "Dishes",
"addDish": "Add dish",
"dishName": "Dish name",
"dishDescription": "Description",
"fridgeDays": "Fridge (days)",
"freezerFriendly": "Freezer-friendly",
"freezerNote": "Freezer note",
"dayOfInstructions": "Day-of instructions",
"dishesRequired": "Add at least one dish",
"dayOfInstructionsRequired": "Every dish needs day-of instructions",
"appliesTo": "Applies to",
"sharedPrep": "Shared prep"
},
"canCook": {
"title": "What can I cook?",
+15 -1
View File
@@ -648,7 +648,21 @@
"discardChangesTitle": "Abandonner les modifications ?",
"discardChangesDescription": "Vous avez des modifications non enregistrées. Si vous quittez maintenant, elles seront perdues.",
"keepEditing": "Continuer l'édition",
"discardChanges": "Abandonner"
"discardChanges": "Abandonner",
"batchCookToggle": "Ceci est une recette de batch cooking",
"batchCookToggleHelp": "Divisez cette recette en plusieurs plats cuisinés ensemble, chacun avec ses propres consignes de conservation et de réchauffage.",
"dishes": "Plats",
"addDish": "Ajouter un plat",
"dishName": "Nom du plat",
"dishDescription": "Description",
"fridgeDays": "Frigo (jours)",
"freezerFriendly": "Se congèle bien",
"freezerNote": "Note de congélation",
"dayOfInstructions": "Consignes le jour J",
"dishesRequired": "Ajoutez au moins un plat",
"dayOfInstructionsRequired": "Chaque plat nécessite des consignes pour le jour J",
"appliesTo": "Concerne",
"sharedPrep": "Préparation commune"
},
"canCook": {
"title": "Que puis-je cuisiner ?",