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:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user