fix: photos lost on save, multi-upload only kept last file, thumbnails 400

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.
This commit is contained in:
Arnaud
2026-07-12 13:16:37 +02:00
parent 77f43ee673
commit 546ba98d2f
12 changed files with 68 additions and 6 deletions
+2
View File
@@ -339,6 +339,7 @@ export default async function RecipePage({ params }: Params) {
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
className="object-cover"
@@ -442,6 +443,7 @@ export default async function RecipePage({ params }: Params) {
<div key={photo.id} className="relative aspect-square rounded-lg overflow-hidden bg-muted">
<Image
src={getPublicUrl(photo.storageKey)}
unoptimized
alt={`${recipe.title} photo ${i + 1}`}
fill
className="object-cover"
+1
View File
@@ -240,6 +240,7 @@ export default async function UserProfilePage({ params, searchParams }: Params)
{cover ? (
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
+31 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots, ratings } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeSnapshots, ratings } from "@epicure/db";
import { eq, and, max, isNotNull } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
@@ -41,6 +41,10 @@ const UpdateRecipeSchema = z.object({
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
})).max(100).optional(),
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).optional(),
});
type Params = { params: Promise<{ id: string }> };
@@ -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") {
+17 -1
View File
@@ -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) });
+1 -1
View File
@@ -127,7 +127,7 @@ export default async function PublicRecipePage({ params }: Params) {
{cover && (
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
<Image src={getPublicUrl(cover.storageKey)} alt={recipe.title} fill className="object-cover" />
<Image src={getPublicUrl(cover.storageKey)} alt={recipe.title} fill unoptimized className="object-cover" />
</div>
)}