From 546ba98d2fa29b7ef623c7e420979b019e9c4e8d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 12 Jul 2026 13:16:37 +0200 Subject: [PATCH] fix: photos lost on save, multi-upload only kept last file, thumbnails 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recipe-form/API routes were never sending/persisting photos on create+update. PhotoUploader accumulated uploads via a stale closure over the photos prop, so only the last file of a multi-select survived. Once both were fixed, thumbnails still 400'd because Next's image optimizer blocks upstream hosts resolving to private/loopback IPs (localhost:9000 MinIO) — skip optimization for storage-hosted photos since they're already web-sized user uploads. --- apps/web/app/(app)/recipes/[id]/page.tsx | 2 ++ apps/web/app/(app)/u/[username]/page.tsx | 1 + apps/web/app/api/v1/recipes/[id]/route.ts | 33 +++++++++++++++++-- apps/web/app/api/v1/recipes/route.ts | 18 +++++++++- apps/web/app/r/[id]/page.tsx | 2 +- apps/web/components/profile/profile-tabs.tsx | 1 + .../recipe/favorite-recipe-card.tsx | 1 + apps/web/components/recipe/photo-uploader.tsx | 11 +++++-- apps/web/components/recipe/recipe-card.tsx | 1 + apps/web/components/recipe/recipe-form.tsx | 1 + apps/web/components/recipe/recipes-grid.tsx | 1 + .../components/social/cooked-it-review.tsx | 2 ++ 12 files changed, 68 insertions(+), 6 deletions(-) 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) {
{recipe.title} {`${recipe.title} }; @@ -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 && (
- {recipe.title} + {recipe.title}
)} 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({
{photo.recipeTitle} Recipe photo {recipe.title} ({ 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 ? ( {recipe.title} Your cooked-it photo