diff --git a/apps/web/app/(app)/recipes/[id]/edit/page.tsx b/apps/web/app/(app)/recipes/[id]/edit/page.tsx index 2ac2656..12416c6 100644 --- a/apps/web/app/(app)/recipes/[id]/edit/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/edit/page.tsx @@ -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 ( diff --git a/apps/web/app/api/v1/recipes/[id]/route.ts b/apps/web/app/api/v1/recipes/[id]/route.ts index 689b14d..7988ae6 100644 --- a/apps/web/app/api/v1/recipes/[id]/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/route.ts @@ -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, })) ); } diff --git a/apps/web/app/api/v1/recipes/route.ts b/apps/web/app/api/v1/recipes/route.ts index b79d8f9..a643496 100644 --- a/apps/web/app/api/v1/recipes/route.ts +++ b/apps/web/app/api/v1/recipes/route.ts @@ -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) }); diff --git a/apps/web/components/recipe/recipe-form.tsx b/apps/web/components/recipe/recipe-form.tsx index 4a228da..a5f7ec5 100644 --- a/apps/web/components/recipe/recipe-form.tsx +++ b/apps/web/components/recipe/recipe-form.tsx @@ -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(defaultValues?.photos ?? []); + const [isBatchCook, setIsBatchCook] = useState(defaultValues?.isBatchCook ?? false); + const [dishes, setDishes] = useState( + 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) { + 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) { + {/* Batch cooking */} +
+
+ + +
+

{t("batchCookToggleHelp")}

+
+ + + {/* Photos */}
@@ -462,38 +553,151 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) { + {/* Batch-cook dishes */} + {isBatchCook && ( + <> +
+ +
+ {dishes.map((dish, i) => ( +
+
+ updateDish(i, { name: e.target.value })} + placeholder={t("dishName")} + className="flex-1 min-w-0 font-medium" + /> + +
+