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:
@@ -339,6 +339,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
|
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(cover.storageKey)}
|
src={getPublicUrl(cover.storageKey)}
|
||||||
|
unoptimized
|
||||||
alt={recipe.title}
|
alt={recipe.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
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">
|
<div key={photo.id} className="relative aspect-square rounded-lg overflow-hidden bg-muted">
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(photo.storageKey)}
|
src={getPublicUrl(photo.storageKey)}
|
||||||
|
unoptimized
|
||||||
alt={`${recipe.title} photo ${i + 1}`}
|
alt={`${recipe.title} photo ${i + 1}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export default async function UserProfilePage({ params, searchParams }: Params)
|
|||||||
{cover ? (
|
{cover ? (
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(cover.storageKey)}
|
src={getPublicUrl(cover.storageKey)}
|
||||||
|
unoptimized
|
||||||
alt={recipe.title}
|
alt={recipe.title}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { eq, and, max, isNotNull } from "@epicure/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
@@ -41,6 +41,10 @@ const UpdateRecipeSchema = z.object({
|
|||||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
})).max(100).optional(),
|
})).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 }> };
|
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({
|
const existing = await db.query.recipes.findFirst({
|
||||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
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 });
|
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);
|
const updated = await getOwnedRecipe(id, session!.user.id);
|
||||||
void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title });
|
void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title });
|
||||||
if (existing.visibility === "private" && data.visibility === "public") {
|
if (existing.visibility === "private" && data.visibility === "public") {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { eq, desc, and } from "@epicure/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
@@ -43,6 +43,10 @@ const CreateRecipeSchema = z.object({
|
|||||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||||
order: z.number().int().optional(),
|
order: z.number().int().optional(),
|
||||||
})).max(100).default([]),
|
})).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) {
|
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) });
|
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default async function PublicRecipePage({ params }: Params) {
|
|||||||
|
|
||||||
{cover && (
|
{cover && (
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export function ProfileTabs({
|
|||||||
<div className="relative aspect-square bg-muted overflow-hidden">
|
<div className="relative aspect-square bg-muted overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(photo.photoKey)}
|
src={getPublicUrl(photo.photoKey)}
|
||||||
|
unoptimized
|
||||||
alt={photo.recipeTitle}
|
alt={photo.recipeTitle}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function FavoriteRecipeCard({
|
|||||||
{recipe.coverPhotoKey ? (
|
{recipe.coverPhotoKey ? (
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(recipe.coverPhotoKey)}
|
src={getPublicUrl(recipe.coverPhotoKey)}
|
||||||
|
unoptimized
|
||||||
alt={recipe.title}
|
alt={recipe.title}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export function PhotoUploader({
|
|||||||
async function handleFiles(files: FileList) {
|
async function handleFiles(files: FileList) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
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)) {
|
for (const file of Array.from(files)) {
|
||||||
const res = await fetch("/api/v1/upload/presign", {
|
const res = await fetch("/api/v1/upload/presign", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -38,8 +43,9 @@ export function PhotoUploader({
|
|||||||
if (!res.ok) continue;
|
if (!res.ok) continue;
|
||||||
const { url, key } = await res.json() as { url: string; key: string };
|
const { url, key } = await res.json() as { url: string; key: string };
|
||||||
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
|
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
|
||||||
const isFirst = photos.length === 0;
|
const isFirst = next.length === 0;
|
||||||
onChange([...photos, { key, isCover: isFirst, preview: URL.createObjectURL(file) }]);
|
next = [...next, { key, isCover: isFirst, preview: URL.createObjectURL(file) }];
|
||||||
|
onChange(next);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
@@ -65,6 +71,7 @@ export function PhotoUploader({
|
|||||||
<div key={photo.key} className="relative group h-24 w-24">
|
<div key={photo.key} className="relative group h-24 w-24">
|
||||||
<Image
|
<Image
|
||||||
src={photo.preview || getPublicUrl(photo.key)}
|
src={photo.preview || getPublicUrl(photo.key)}
|
||||||
|
unoptimized
|
||||||
alt="Recipe photo"
|
alt="Recipe photo"
|
||||||
fill
|
fill
|
||||||
className={`rounded-lg object-cover border-2 ${
|
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">
|
<div className="relative aspect-video overflow-hidden bg-muted">
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(cover.storageKey)}
|
src={getPublicUrl(cover.storageKey)}
|
||||||
|
unoptimized
|
||||||
alt={recipe.title}
|
alt={recipe.title}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
dietaryTags,
|
dietaryTags,
|
||||||
ingredients: filteredIngredients,
|
ingredients: filteredIngredients,
|
||||||
steps: filteredSteps,
|
steps: filteredSteps,
|
||||||
|
photos: photos.map((p) => ({ key: p.key, isCover: p.isCover })),
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
|
|||||||
{cover ? (
|
{cover ? (
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(cover.storageKey)}
|
src={getPublicUrl(cover.storageKey)}
|
||||||
|
unoptimized
|
||||||
alt={recipe.title}
|
alt={recipe.title}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export function CookedItReview({
|
|||||||
<div className="relative h-16 w-16">
|
<div className="relative h-16 w-16">
|
||||||
<Image
|
<Image
|
||||||
src={preview ?? getPublicUrl(photoKey!)}
|
src={preview ?? getPublicUrl(photoKey!)}
|
||||||
|
unoptimized
|
||||||
alt="Your cooked-it photo"
|
alt="Your cooked-it photo"
|
||||||
fill
|
fill
|
||||||
className="rounded-lg object-cover border"
|
className="rounded-lg object-cover border"
|
||||||
@@ -209,6 +210,7 @@ export function CookedItReview({
|
|||||||
<div className="relative h-32 w-32">
|
<div className="relative h-32 w-32">
|
||||||
<Image
|
<Image
|
||||||
src={getPublicUrl(r.photoKey)}
|
src={getPublicUrl(r.photoKey)}
|
||||||
|
unoptimized
|
||||||
alt={`${r.user.name}'s cooked-it photo`}
|
alt={`${r.user.name}'s cooked-it photo`}
|
||||||
fill
|
fill
|
||||||
className="rounded-lg object-cover border"
|
className="rounded-lg object-cover border"
|
||||||
|
|||||||
Reference in New Issue
Block a user