d7e0d7eada
New users get a Gravatar-backed avatar automatically (computed from email at signup); users can upload a custom photo instead via Settings, or revert to the Gravatar/initials fallback. avatarUrl stays the single resolved value (custom photo, OAuth photo, or precomputed Gravatar URL) so every existing avatar-rendering spot across the app needs zero changes. - users.hasCustomAvatar tracks whether avatarUrl is a real upload vs a computed Gravatar fallback, so "remove photo" knows what to revert to. - New /api/v1/upload/avatar-presign route (session-scoped, generic — the existing recipe-photo presign route required a recipeId). - CSP img-src needed www.gravatar.com added, or every browser blocks the fallback avatar outright. - Settings page now reads avatarUrl from a fresh DB query instead of Better Auth's session object — the session cookie cache (5 min TTL) was serving a stale image right after upload, showing the initials fallback until the cache happened to expire. Verified locally: signup auto-sets a Gravatar URL, upload persists across reload, remove correctly reverts to Gravatar/initials.
127 lines
4.2 KiB
TypeScript
127 lines
4.2 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";
|
|
|
|
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 } = await presignRes.json() as { url: string; key: string };
|
|
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
|
|
|
|
// The presigned PUT URL is already publicUrl/bucket/key with query-string
|
|
// signing params appended — strip those to get the plain public GET URL.
|
|
const saveRes = await fetch("/api/v1/users/me", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ avatarUrl: url.split("?")[0] }),
|
|
});
|
|
if (!saveRes.ok) {
|
|
toast.error(t("avatarUploadFailed"));
|
|
return;
|
|
}
|
|
const saved = await saveRes.json() as { avatarUrl?: string };
|
|
onChange(saved.avatarUrl ?? url.split("?")[0]!, 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({ avatarUrl: 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>
|
|
);
|
|
}
|