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
@@ -147,6 +147,7 @@ export function ProfileTabs({
<div className="relative aspect-square bg-muted overflow-hidden">
<Image
src={getPublicUrl(photo.photoKey)}
unoptimized
alt={photo.recipeTitle}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
@@ -55,6 +55,7 @@ export function FavoriteRecipeCard({
{recipe.coverPhotoKey ? (
<Image
src={getPublicUrl(recipe.coverPhotoKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
@@ -29,6 +29,11 @@ export function PhotoUploader({
async function handleFiles(files: FileList) {
setUploading(true);
try {
// Accumulate locally instead of calling onChange(...photos, entry) per file —
// `photos` is a stale closure over the prop from when handleFiles was called,
// so multiple onChange calls in this loop would each overwrite the previous
// one's addition instead of stacking (only the last uploaded file would stick).
let next = photos;
for (const file of Array.from(files)) {
const res = await fetch("/api/v1/upload/presign", {
method: "POST",
@@ -38,8 +43,9 @@ export function PhotoUploader({
if (!res.ok) continue;
const { url, key } = await res.json() as { url: string; key: string };
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
const isFirst = photos.length === 0;
onChange([...photos, { key, isCover: isFirst, preview: URL.createObjectURL(file) }]);
const isFirst = next.length === 0;
next = [...next, { key, isCover: isFirst, preview: URL.createObjectURL(file) }];
onChange(next);
}
} finally {
setUploading(false);
@@ -65,6 +71,7 @@ export function PhotoUploader({
<div key={photo.key} className="relative group h-24 w-24">
<Image
src={photo.preview || getPublicUrl(photo.key)}
unoptimized
alt="Recipe photo"
fill
className={`rounded-lg object-cover border-2 ${
@@ -46,6 +46,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
<div className="relative aspect-video overflow-hidden bg-muted">
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
@@ -231,6 +231,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
dietaryTags,
ingredients: filteredIngredients,
steps: filteredSteps,
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
};
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
@@ -70,6 +70,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
{cover ? (
<Image
src={getPublicUrl(cover.storageKey)}
unoptimized
alt={recipe.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
@@ -147,6 +147,7 @@ export function CookedItReview({
<div className="relative h-16 w-16">
<Image
src={preview ?? getPublicUrl(photoKey!)}
unoptimized
alt="Your cooked-it photo"
fill
className="rounded-lg object-cover border"
@@ -209,6 +210,7 @@ export function CookedItReview({
<div className="relative h-32 w-32">
<Image
src={getPublicUrl(r.photoKey)}
unoptimized
alt={`${r.user.name}'s cooked-it photo`}
fill
className="rounded-lg object-cover border"