Files
Epicure/apps/web/components/settings/avatar-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

133 lines
4.5 KiB
TypeScript

"use client";
import { useState, useRef } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Camera, Loader2, X } from "lucide-react";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { uploadToPresignedPost } from "@/lib/upload-client";
export function AvatarUploader({
name,
image,
hasCustomAvatar,
onChange,
}: {
name: string;
image: string | null;
hasCustomAvatar: boolean;
onChange: (image: string | null, hasCustomAvatar: boolean) => void;
}) {
const t = useTranslations("settingsForm");
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
setUploading(true);
setPreview(URL.createObjectURL(file));
try {
const presignRes = await fetch("/api/v1/upload/avatar-presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSize: file.size }),
});
if (!presignRes.ok) {
const err = await presignRes.json() as { error?: string };
toast.error(err.error ?? t("avatarUploadFailed"));
return;
}
const { url, fields, key, sizeMb } = await presignRes.json() as { url: string; fields: Record<string, string>; key: string; sizeMb: number };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) {
toast.error(t("avatarUploadFailed"));
return;
}
// Send back the storage key we were issued, not a client-derived URL —
// the server validates the key belongs to us and builds the public URL
// itself, so a client can't set avatarUrl to an arbitrary/unowned target.
const saveRes = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarKey: key, avatarSizeMb: sizeMb }),
});
if (!saveRes.ok) {
toast.error(t("avatarUploadFailed"));
return;
}
const saved = await saveRes.json() as { avatarUrl?: string };
onChange(saved.avatarUrl ?? null, true);
toast.success(t("avatarUploadSuccess"));
} finally {
setUploading(false);
}
}
async function handleRemove() {
setUploading(true);
try {
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarKey: null }),
});
if (!res.ok) {
toast.error(t("avatarUploadFailed"));
return;
}
const saved = await res.json() as { avatarUrl?: string };
setPreview(null);
onChange(saved.avatarUrl ?? null, false);
toast.success(t("avatarRemoved"));
} finally {
setUploading(false);
}
}
return (
<div className="flex items-center gap-4">
<div className="relative">
<Avatar size="lg" className="size-16">
<AvatarImage src={preview ?? image ?? undefined} alt={name} />
<AvatarFallback className="text-lg">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
{uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40">
<Loader2 className="h-5 w-5 animate-spin text-white" />
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
e.target.value = "";
}}
/>
<Button type="button" variant="outline" size="sm" onClick={() => inputRef.current?.click()} disabled={uploading}>
<Camera className="h-4 w-4" />
{t("changePhoto")}
</Button>
{hasCustomAvatar && (
<button
type="button"
onClick={() => { void handleRemove(); }}
disabled={uploading}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
>
<X className="h-3 w-3" />
{t("removePhoto")}
</button>
)}
</div>
</div>
);
}