f6975e98a9
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>
132 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|