diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx
index 5834f66..f6a3271 100644
--- a/apps/web/app/(app)/recipes/[id]/page.tsx
+++ b/apps/web/app/(app)/recipes/[id]/page.tsx
@@ -339,6 +339,7 @@ export default async function RecipePage({ params }: Params) {
};
@@ -70,7 +74,11 @@ export async function PUT(req: NextRequest, { params }: Params) {
const existing = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
- with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) } },
+ with: {
+ ingredients: { orderBy: (t, { asc }) => asc(t.order) },
+ steps: { orderBy: (t, { asc }) => asc(t.order) },
+ photos: true,
+ },
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
@@ -162,8 +170,29 @@ export async function PUT(req: NextRequest, { params }: Params) {
);
}
}
+
+ if (data.photos !== undefined) {
+ await tx.delete(recipePhotos).where(eq(recipePhotos.recipeId, id));
+ if (data.photos.length > 0) {
+ await tx.insert(recipePhotos).values(
+ data.photos.map((photo, i) => ({
+ id: crypto.randomUUID(),
+ recipeId: id,
+ storageKey: photo.key,
+ order: i,
+ isCover: photo.isCover,
+ }))
+ );
+ }
+ }
});
+ if (data.photos !== undefined) {
+ const newKeys = new Set(data.photos.map((p) => p.key));
+ const removedKeys = existing.photos.filter((p) => !newKeys.has(p.storageKey)).map((p) => p.storageKey);
+ await Promise.all(removedKeys.map((key) => deleteObject(key).catch(() => {})));
+ }
+
const updated = await getOwnedRecipe(id, session!.user.id);
void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title });
if (existing.visibility === "private" && data.visibility === "public") {
diff --git a/apps/web/app/api/v1/recipes/route.ts b/apps/web/app/api/v1/recipes/route.ts
index 0465ef3..b79d8f9 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 } from "@epicure/db";
+import { db, recipes, recipeIngredients, recipeSteps, recipePhotos } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
@@ -43,6 +43,10 @@ const CreateRecipeSchema = z.object({
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
})).max(100).default([]),
+ photos: z.array(z.object({
+ key: z.string().min(1).max(500),
+ isCover: z.boolean().default(false),
+ })).max(20).default([]),
});
export async function GET(req: NextRequest) {
@@ -135,6 +139,18 @@ export async function POST(req: NextRequest) {
}))
);
}
+
+ if (data.photos.length > 0) {
+ await tx.insert(recipePhotos).values(
+ data.photos.map((photo, i) => ({
+ id: crypto.randomUUID(),
+ recipeId: id,
+ storageKey: photo.key,
+ order: i,
+ isCover: photo.isCover,
+ }))
+ );
+ }
});
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx
index 08b3a0a..06248b4 100644
--- a/apps/web/app/r/[id]/page.tsx
+++ b/apps/web/app/r/[id]/page.tsx
@@ -127,7 +127,7 @@ export default async function PublicRecipePage({ params }: Params) {
{cover && (
-
+
)}
diff --git a/apps/web/components/profile/profile-tabs.tsx b/apps/web/components/profile/profile-tabs.tsx
index dfd09b7..b5131dd 100644
--- a/apps/web/components/profile/profile-tabs.tsx
+++ b/apps/web/components/profile/profile-tabs.tsx
@@ -147,6 +147,7 @@ export function ProfileTabs({
({ key: p.key, isCover: p.isCover })),
};
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx
index 496a54e..f697b8d 100644
--- a/apps/web/components/recipe/recipes-grid.tsx
+++ b/apps/web/components/recipe/recipes-grid.tsx
@@ -70,6 +70,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
{cover ? (