Files
Epicure/apps/web/components/recipe/photo-uploader.tsx
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00

132 lines
4.7 KiB
TypeScript

"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Upload, X, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getPublicUrl } from "@/lib/storage";
import { uploadToPresignedPost } from "@/lib/upload-client";
export type PhotoEntry = {
key: string;
isCover: boolean;
preview: string;
sizeMb: number;
};
export function PhotoUploader({
recipeId,
photos,
onChange,
}: {
recipeId: string;
photos: PhotoEntry[];
onChange: (photos: PhotoEntry[]) => void;
}) {
const t = useTranslations("recipeForm");
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, contentType: file.type, fileSize: file.size }),
});
if (!res.ok) continue;
const { url, fields, key, sizeMb } = await res.json() as { url: string; fields: Record<string, string>; key: string; sizeMb: number };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) continue;
const isFirst = next.length === 0;
next = [...next, { key, isCover: isFirst, preview: URL.createObjectURL(file), sizeMb }];
onChange(next);
}
} finally {
setUploading(false);
}
}
function setCover(key: string) {
onChange(photos.map((p) => ({ ...p, isCover: p.key === key })));
}
function remove(key: string) {
const filtered = photos.filter((p) => p.key !== key);
if (filtered.length > 0 && !filtered.some((p) => p.isCover)) {
filtered[0]!.isCover = true;
}
onChange(filtered);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
{photos.map((photo) => (
<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 ${
photo.isCover ? "border-primary" : "border-transparent"
}`}
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity flex items-center justify-center gap-1">
<button
type="button"
onClick={() => setCover(photo.key)}
className="rounded-full bg-white/20 p-1 hover:bg-white/40 transition-colors"
title={t("setCover")}
>
<Star className={`h-3 w-3 ${photo.isCover ? "text-yellow-400" : "text-white"}`} />
</button>
<button
type="button"
onClick={() => remove(photo.key)}
className="rounded-full bg-white/20 p-1 hover:bg-red-500/80 transition-colors"
title={t("removePhoto")}
>
<X className="h-3 w-3 text-white" />
</button>
</div>
{photo.isCover && (
<span className="absolute bottom-1 left-1 text-[10px] bg-primary text-primary-foreground rounded px-1">
cover
</span>
)}
</div>
))}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="h-24 w-24 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
>
<Upload className="h-4 w-4" />
<span className="text-xs">{uploading ? "Uploading…" : "Add photo"}</span>
</button>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
multiple
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
</div>
);
}