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>
133 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|