Repository: BrunoAseff/flaira
Pull request #112: Develop
Author: login: BrunoAseff
Created: 2025-05-28T03:19:16Z | Merged: 2025-05-29T11:28:50Z



<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced an improved avatar upload experience with image cropping, preview, and client-side validation.
  - Added the ability to remove your avatar directly from profile settings.
  - Enhanced avatar display with interactive options for updating or removing your profile picture.

- **Bug Fixes**
  - Improved avatar image scaling to ensure profile pictures display correctly in the sidebar.

- **Style**
  - Updated the appearance and spacing of settings dialogs and profile forms for better usability on all screen sizes.
  - Increased font sizes and icon sizes in settings tabs for improved readability.

- **Chores**
  - Added a new dependency to support image cropping functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/apps/frontend/src/components/settings/AvatarUpload.tsx b/apps/frontend/src/components/settings/AvatarUpload.tsx
deleted file mode 100644
index f78f645..0000000
--- a/apps/frontend/src/components/settings/AvatarUpload.tsx
+++ /dev/null
@@ -1,371 +0,0 @@
-"use client";
-
-import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
-import { HugeiconsIcon } from "@hugeicons/react";
-import {
-  User03Icon,
-  PaintBrush01Icon,
-  Loading01Icon,
-  Cancel01Icon,
-} from "@hugeicons/core-free-icons";
-import { Input } from "../ui/input";
-import { auth } from "@/auth/client";
-import { useState, type ChangeEvent, useRef } from "react";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import type { User } from "better-auth/types";
-import { Banner } from "../ui/banner";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuTrigger,
-  DropdownMenuSeparator,
-} from "../ui/dropdown-menu";
-import { cn } from "@/lib/utils";
-
-export default function AvatarUpload({ user }: { user: User | null }) {
-  const queryClient = useQueryClient();
-  const [clientSideUploadError, setClientSideUploadError] = useState<
-    string | null
-  >(null);
-  const [optimisticAvatarUrl, setOptimisticAvatarUrl] = useState<string | null>(
-    null,
-  );
-  const fileInputRef = useRef<HTMLInputElement>(null);
-
-  const { data: imageUrl, isLoading: isImageUrlLoading } = useQuery({
-    queryKey: ["avatar-url", user?.id, user?.image],
-    queryFn: async () => {
-      if (!user?.image) {
-        return null;
-      }
-      const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/user/get-avatar`);
-      url.searchParams.append("key", user.image);
-
-      const presignedUrlResponse = await fetch(url.toString(), {
-        method: "GET",
-        headers: { "Content-Type": "application/json" },
-        credentials: "include",
-      });
-
-      if (!presignedUrlResponse.ok) {
-        console.error(
-          "Failed to fetch avatar URL:",
-          presignedUrlResponse.status,
-        );
-        return null;
-      }
-      const presignedUrlData = await presignedUrlResponse.json();
-      return presignedUrlData.data || null;
-    },
-    enabled: !!user && !!user.image,
-    staleTime: 5 * 60 * 1000,
-    retry: 2,
-  });
-
-  const deleteS3ObjectMutation = useMutation({
-    mutationFn: async (key: string) => {
-      const url = new URL(
-        `${process.env.NEXT_PUBLIC_API_URL}/user/delete-avatar`,
-      );
-      url.searchParams.append("key", key);
-      const response = await fetch(url.toString(), {
-        method: "DELETE",
-        headers: { "Content-Type": "application/json" },
-        credentials: "include",
-      });
-      if (!response.ok) {
-        const errorData = await response.json().catch(() => ({}));
-        throw new Error(
-          errorData.message || "Failed to delete avatar from storage.",
-        );
-      }
-      return response.json();
-    },
-  });
-
-  const uploadAvatarMutation = useMutation({
-    mutationFn: async (file: File) => {
-      setClientSideUploadError(null);
-      const fileName = encodeURIComponent(file.name);
-      const type = file.type;
-
-      const presignedUrlResponse = await fetch(
-        `${process.env.NEXT_PUBLIC_API_URL}/user/upload-avatar`,
-        {
-          method: "POST",
-          headers: { "Content-Type": "application/json" },
-          credentials: "include",
-          body: JSON.stringify({ fileName, type }),
-        },
-      );
-      if (!presignedUrlResponse.ok) {
-        const errorData = await presignedUrlResponse.json().catch(() => ({}));
-        throw new Error(errorData.message || "Failed to get upload URL.");
-      }
-      const { url: S3UploadUrl, key: newImageKey } = (
-        await presignedUrlResponse.json()
-      ).data;
-
-      const uploadResponse = await fetch(S3UploadUrl, {
-        method: "PUT",
-        headers: { "Content-Type": type },
-        body: file,
-      });
-      if (!uploadResponse.ok) {
-        throw new Error("File upload to storage failed.");
-      }
-
-      const oldImageKey = user?.image;
-      if (oldImageKey && oldImageKey !== newImageKey) {
-        await deleteS3ObjectMutation.mutateAsync(oldImageKey);
-      }
-
-      const updateUserResponse = await auth.updateUser({ image: newImageKey });
-      if (updateUserResponse.error) {
-        try {
-          await deleteS3ObjectMutation.mutateAsync(newImageKey);
-        } catch (cleanupError) {
-          console.error(
-            "Failed to clean up newly uploaded avatar after DB update failure:",
-            cleanupError,
-          );
-        }
-        throw new Error(
-          updateUserResponse.error.message || "Failed to update user profile.",
-        );
-      }
-      return updateUserResponse.data;
-    },
-    onMutate: async (file: File) => {
-      const localUrl = URL.createObjectURL(file);
-      setOptimisticAvatarUrl(localUrl);
-      await queryClient.cancelQueries({
-        queryKey: ["avatar-url", user?.id, user?.image],
-      });
-      return { localUrl };
-    },
-    onSuccess: (_data, _variables, _context) => {
-      queryClient.invalidateQueries({ queryKey: ["currentSession"] });
-      queryClient.invalidateQueries({ queryKey: ["avatar-url"], exact: false });
-      setOptimisticAvatarUrl(null);
-    },
-    onError: (error, _variables, context) => {
-      setClientSideUploadError(error.message || "Avatar upload failed.");
-      if (context?.localUrl) {
-        URL.revokeObjectURL(context.localUrl);
-      }
-      setOptimisticAvatarUrl(null);
-    },
-    onSettled: (_data, _error, _variables, context) => {
-      if (context?.localUrl) {
-        URL.revokeObjectURL(context.localUrl);
-      }
-      if (fileInputRef.current) {
-        fileInputRef.current.value = "";
-      }
-    },
-  });
-
-  const removeAvatarMutation = useMutation({
-    mutationFn: async () => {
-      if (!user?.image) {
-        throw new Error("No avatar to remove.");
-      }
-      const imageKeyToRemove = user.image;
-
-      await deleteS3ObjectMutation.mutateAsync(imageKeyToRemove);
-
-      const updateUserResponse = await auth.updateUser({ image: null });
-      if (updateUserResponse.error) {
-        throw new Error(
-          updateUserResponse.error.message ||
-            "Failed to update user profile after removing avatar.",
-        );
-      }
-      return updateUserResponse.data;
-    },
-    onMutate: async () => {
-      setOptimisticAvatarUrl(null);
-      await queryClient.cancelQueries({
-        queryKey: ["avatar-url", user?.id, user?.image],
-      });
-      const previousImageUrl = queryClient.getQueryData([
-        "avatar-url",
-        user?.id,
-        user?.image,
-      ]);
-      queryClient.setQueryData(["avatar-url", user?.id, user?.image], null);
-      return { previousImageUrl };
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["currentSession"] });
-      queryClient.invalidateQueries({ queryKey: ["avatar-url"] });
-      setOptimisticAvatarUrl(null);
-      setClientSideUploadError(null);
-    },
-    onError: (error, _variables, context) => {
-      setClientSideUploadError(error.message || "Failed to remove avatar.");
-      if (context?.previousImageUrl) {
-        queryClient.setQueryData(
-          ["avatar-url", user?.id, user?.image],
-          context.previousImageUrl,
-        );
-      }
-    },
-  });
-
-  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
-    const file = event.target.files?.[0];
-    if (file) {
-      if (file.size > 5 * 1024 * 1024) {
-        setClientSideUploadError("File is too large (max 5MB).");
-        uploadAvatarMutation.reset();
-        return;
-      }
-      if (
-        !["image/jpeg", "image/png", "image/webp", "image/jpg"].includes(
-          file.type,
-        )
-      ) {
-        setClientSideUploadError(
-          "Invalid file type. Please use JPG, PNG, or WEBP.",
-        );
-        uploadAvatarMutation.reset();
-        return;
-      }
-      setClientSideUploadError(null);
-      uploadAvatarMutation.mutate(file);
-    }
-    if (event.target) {
-      event.target.value = "";
-    }
-  };
-
-  const handleUpdateAvatarClick = () => {
-    fileInputRef.current?.click();
-  };
-
-  const handleRemoveAvatarClick = () => {
-    if (user?.image) {
-      removeAvatarMutation.mutate();
-    } else {
-      setClientSideUploadError("No avatar to remove.");
-    }
-  };
-
-  const displayImageSrc =
-    optimisticAvatarUrl !== null ? optimisticAvatarUrl : imageUrl;
-  const showRemoveOption = !!(user?.image || optimisticAvatarUrl);
-  const isActionPending =
-    uploadAvatarMutation.isPending ||
-    removeAvatarMutation.isPending ||
-    isImageUrlLoading;
-
-  return (
-    <div className="flex flex-col items-center gap-3 pt-6">
-      <DropdownMenu>
-        <DropdownMenuTrigger asChild disabled={isActionPending}>
-          <div className="relative group cursor-pointer">
-            <Avatar className="size-24 border-2 border-muted">
-              <AvatarImage
-                src={displayImageSrc || ""}
-                alt={`${user?.name || "User"}'s profile picture`}
-                key={displayImageSrc}
-              />
-              <AvatarFallback className="bg-background text-foreground size-full">
-                {isActionPending && !optimisticAvatarUrl ? (
-                  <HugeiconsIcon
-                    icon={Loading01Icon}
-                    className="text-primary"
-                    size={32}
-                  />
-                ) : (
-                  <HugeiconsIcon
-                    icon={User03Icon}
-                    color="currentColor"
-                    strokeWidth={1.5}
-                    size={48}
-                  />
-                )}
-              </AvatarFallback>
-            </Avatar>
-            {!isActionPending && !optimisticAvatarUrl && (
-              <div
-                className={cn(
-                  "absolute inset-0 flex items-center justify-center bg-primary-foreground text-primary rounded-full",
-                  "opacity-0 group-hover:opacity-100 transition-opacity duration-200",
-                )}
-              >
-                <HugeiconsIcon icon={PaintBrush01Icon} size={32} />
-              </div>
-            )}
-            {uploadAvatarMutation.isPending && optimisticAvatarUrl && (
-              <div className="absolute inset-0 flex items-center justify-center bg-muted/60 rounded-full">
-                <HugeiconsIcon
-                  icon={Loading01Icon}
-                  className="animate-spin text-primary"
-                  size={32}
-                />
-              </div>
-            )}
-          </div>
-        </DropdownMenuTrigger>
-        <DropdownMenuContent align="center">
-          <DropdownMenuItem
-            onClick={handleUpdateAvatarClick}
-            disabled={isActionPending}
-            className="group flex items-center justify-between"
-          >
-            <HugeiconsIcon
-              icon={PaintBrush01Icon}
-              className="text-foreground/70 group-hover:text-foreground"
-              strokeWidth={2}
-              size={18}
-            />
-            <span>Update avatar</span>
-          </DropdownMenuItem>
-
-          {showRemoveOption && (
-            <>
-              <DropdownMenuSeparator />
-              <DropdownMenuItem
-                onClick={handleRemoveAvatarClick}
-                disabled={isActionPending || removeAvatarMutation.isPending}
-                className="group flex items-center justify-between"
-                variant="destructive"
-              >
-                <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} size={18} />
-                <span>Remove avatar</span>
-              </DropdownMenuItem>
-            </>
-          )}
-        </DropdownMenuContent>
-      </DropdownMenu>
-
-      <Input
-        id="avatarUpload"
-        type="file"
-        ref={fileInputRef}
-        className="hidden"
-        accept="image/png, image/jpeg, image/webp, image/jpg"
-        onChange={handleFileChange}
-        disabled={uploadAvatarMutation.isPending}
-      />
-
-      {(uploadAvatarMutation.isError ||
-        removeAvatarMutation.isError ||
-        clientSideUploadError) && (
-        <Banner
-          variant="error"
-          className="w-full max-w-xs text-sm text-center mt-2"
-        >
-          {clientSideUploadError ||
-            uploadAvatarMutation.error?.message ||
-            removeAvatarMutation.error?.message ||
-            "An error occurred."}
-        </Banner>
-      )}
-    </div>
-  );
-}
diff --git a/apps/frontend/src/components/settings/DeleteAccountDialog.tsx b/apps/frontend/src/components/settings/DeleteAccountDialog.tsx
index 39734f0..c998997 100644
--- a/apps/frontend/src/components/settings/DeleteAccountDialog.tsx
+++ b/apps/frontend/src/components/settings/DeleteAccountDialog.tsx
@@ -64,12 +64,12 @@ export function DeleteAccountDialog({
 
   return (
     <Dialog open={isOpen} onOpenChange={setIsOpen}>
-      <DialogContent className="bg-background p-4 pb-0 max-w-[30rem] overflow-hidden">
+      <DialogContent className="bg-background p-4 pb-0 max-w-full h-[100dvh] md:h-fit md:max-w-[30rem] overflow-hidden">
         <DialogHeader>
-          <DialogTitle className="ml-5 mb-2 text-foreground text-xl font-semibold">
+          <DialogTitle className="ml-2 mb-2 md:font-semibold text-foreground mt-auto md:mt-0 text-xl font-bold">
             Delete account
           </DialogTitle>
-          <DialogDescription className="ml-5 mb-6 text-foreground/60 text-base font-semibold">
+          <DialogDescription className="ml-2 mt-1 max-w-none md:max-w-[85%]  mb-3 text-foreground/60 text-base font-semibold">
             This action is permanent and cannot be undone. All your data will be
             permanently deleted.
           </DialogDescription>
diff --git a/apps/frontend/src/components/settings/ProfileTab.tsx b/apps/frontend/src/components/settings/ProfileTab.tsx
index a49123d..2e2ecf8 100644
--- a/apps/frontend/src/components/settings/ProfileTab.tsx
+++ b/apps/frontend/src/components/settings/ProfileTab.tsx
@@ -5,7 +5,7 @@ import { ScrollArea } from "../ui/scroll-area";
 import type { User } from "better-auth/types";
 import { name as nameSchema } from "../../schemas/auth";
 import { Banner } from "../ui/banner";
-import AvatarUpload from "./AvatarUpload";
+import AvatarUpload from "./avatar/AvatarUpload";
 import { Input } from "../ui/input";
 import { auth } from "@/auth/client";
 import { useState, useEffect, useRef } from "react";
@@ -94,7 +94,7 @@ export default function ProfileTab({ user, error }: ProfileTabProps) {
                   onChange={(e) =>
                     handleNameInputChange({ name: e.target.value })
                   }
-                  className=" text-foreground/80 p-2 max-w-64 ml-auto text-xs"
+                  className=" text-foreground/80 p-2 max-w-64 ml-auto text-base"
                 />
               </div>
               <em className="ml-auto h-1">
@@ -116,7 +116,7 @@ export default function ProfileTab({ user, error }: ProfileTabProps) {
                 type="email"
                 autoComplete="email"
                 defaultValue={user?.email || "N/A"}
-                className=" text-foreground/80 p-2 max-w-64 ml-auto text-xs"
+                className=" text-foreground/80 p-2 max-w-64 ml-auto text-base"
                 disabled
               />
             </div>
diff --git a/apps/frontend/src/components/settings/SettingsDialog.tsx b/apps/frontend/src/components/settings/SettingsDialog.tsx
index b2a2692..847ee27 100644
--- a/apps/frontend/src/components/settings/SettingsDialog.tsx
+++ b/apps/frontend/src/components/settings/SettingsDialog.tsx
@@ -71,38 +71,37 @@ export function SettingsDialog({
             <TabsList
               className={cn(
                 "flex-shrink-0 bg-transparent gap-3 my-3 w-full md:w-44 justify-center md:justify-start",
-                "flex-row md:flex-col md:mt-0 md:pr-4 mb-4 md:mb-0",
+                "flex-row md:flex-col mt-6 md:mt-0 md:pr-4 mb-4 md:mb-0",
               )}
             >
               <TabsTrigger
                 value="profile"
-                className="flex-col sm:flex-row items-center px-4 py-3 text-sm sm:text-base"
+                className="flex-col sm:flex-row items-center px-4 py-3 text-xl md:text-base"
               >
                 <HugeiconsIcon
                   icon={UserSquareIcon}
                   color="currentColor"
                   strokeWidth={2}
                   size={20}
-                  className="size-5 sm:size-6"
+                  className="size-8 sm:size-6"
                 />
                 Profile
               </TabsTrigger>
               <TabsTrigger
                 value="security"
-                className="flex-col sm:flex-row items-center px-4 py-3 text-sm sm:text-base"
+                className="flex-col sm:flex-row items-center px-4 py-3 text-xl md:text-base"
               >
                 <HugeiconsIcon
                   icon={LockKeyIcon}
                   color="currentColor"
                   strokeWidth={2}
                   size={20}
-                  className="size-5 sm:size-6"
+                  className="size-8 sm:size-6"
                 />
                 Security
               </TabsTrigger>
             </TabsList>
 
-            <Separator orientation="horizontal" className="md:hidden mr-2" />
             <Separator
               orientation="vertical"
               className="h-auto hidden md:block mx-2 md:mx-4"
@@ -110,13 +109,13 @@ export function SettingsDialog({
 
             <div className="flex-1 w-full h-full overflow-y-auto">
               <TabsContent
-                className="w-full h-full mt-6 md:mt-0 outline-none ring-0 focus-visible:ring-0 focus-visible:outline-none :pl-2 lg:pl-4"
+                className="w-full h-full mt-12 md:mt-0 outline-none ring-0 focus-visible:ring-0 focus-visible:outline-none :pl-2 lg:pl-4"
                 value="profile"
               >
                 <ProfileTab user={session?.user ?? null} error={null} />
               </TabsContent>
               <TabsContent
-                className="w-full h-full outline-none ring-0 focus-visible:ring-0 focus-visible:outline-none mt-0 md:pl-2 lg:pl-4"
+                className="w-full h-full mt-12 md:mt-0 outline-none ring-0 focus-visible:ring-0 focus-visible:outline-none  md:pl-2 lg:pl-4"
                 value="security"
               >
                 <SecurityTab
diff --git a/apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx b/apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx
new file mode 100644
index 0000000..4622fb5
--- /dev/null
+++ b/apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx
@@ -0,0 +1,124 @@
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { cn } from "@/lib/utils";
+import {
+  Cancel01Icon,
+  Loading01Icon,
+  PaintBrush01Icon,
+  User03Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { UseMutationResult } from "@tanstack/react-query";
+import type { User } from "better-auth/types";
+
+interface AvatarDisplayProps {
+  displayImageSrc: string | null;
+  user: User | null;
+  isActionPending: boolean;
+  isImageUrlLoading: boolean;
+  optimisticAvatarUrl: string | null;
+  uploadAvatarMutation: UseMutationResult<unknown, Error, File, unknown>;
+  showRemoveOption: boolean;
+  onUpdateAvatarClick: () => void;
+  onRemoveAvatarClick: () => void;
+}
+
+export default function AvatarDisplay({
+  displayImageSrc,
+  user,
+  isActionPending,
+  isImageUrlLoading,
+  optimisticAvatarUrl,
+  uploadAvatarMutation,
+  showRemoveOption,
+  onUpdateAvatarClick,
+  onRemoveAvatarClick,
+}: AvatarDisplayProps) {
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild disabled={isActionPending}>
+        <div className="relative group cursor-pointer">
+          <Avatar className="size-24 border-2 border-muted">
+            <AvatarImage
+              src={displayImageSrc || ""}
+              alt={`${user?.name || "User"}'s profile picture`}
+              key={displayImageSrc}
+              className="object-cover w-full h-full"
+            />
+            <AvatarFallback className="bg-background text-foreground size-full">
+              {isImageUrlLoading && !optimisticAvatarUrl ? (
+                <HugeiconsIcon
+                  icon={Loading01Icon}
+                  className="animate-spin text-primary"
+                  size={32}
+                />
+              ) : (
+                <HugeiconsIcon
+                  icon={User03Icon}
+                  color="currentColor"
+                  strokeWidth={1.5}
+                  size={48}
+                />
+              )}
+            </AvatarFallback>
+          </Avatar>
+          {!isActionPending && !optimisticAvatarUrl && (
+            <div
+              className={cn(
+                "absolute inset-0 flex items-center justify-center bg-primary-foreground text-primary rounded-full",
+                "opacity-0 group-hover:opacity-100 transition-opacity duration-200",
+              )}
+            >
+              <HugeiconsIcon icon={PaintBrush01Icon} size={32} />
+            </div>
+          )}
+          {uploadAvatarMutation.isPending && optimisticAvatarUrl && (
+            <div className="absolute inset-0 flex items-center justify-center bg-muted/60 rounded-full">
+              <HugeiconsIcon
+                icon={Loading01Icon}
+                className="animate-spin text-primary"
+                size={32}
+              />
+            </div>
+          )}
+        </div>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="center">
+        <DropdownMenuItem
+          onClick={onUpdateAvatarClick}
+          disabled={isActionPending}
+          className="group flex items-center gap-2"
+        >
+          <HugeiconsIcon
+            icon={PaintBrush01Icon}
+            className="text-foreground/70 group-hover:text-foreground"
+            strokeWidth={2}
+            size={18}
+          />
+          <span>Update avatar</span>
+        </DropdownMenuItem>
+
+        {showRemoveOption && (
+          <>
+            <DropdownMenuSeparator />
+            <DropdownMenuItem
+              onClick={onRemoveAvatarClick}
+              disabled={isActionPending}
+              className="group flex items-center gap-2"
+              variant="destructive"
+            >
+              <HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} size={18} />
+              <span>Remove avatar</span>
+            </DropdownMenuItem>
+          </>
+        )}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  );
+}
diff --git a/apps/frontend/src/components/settings/avatar/AvatarUpload.tsx b/apps/frontend/src/components/settings/avatar/AvatarUpload.tsx
new file mode 100644
index 0000000..ea7ced6
--- /dev/null
+++ b/apps/frontend/src/components/settings/avatar/AvatarUpload.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import type { User } from "better-auth/types";
+import { type ChangeEvent, useEffect, useRef, useState } from "react";
+import AvatarDisplay from "./AvatarDisplay";
+import { Input } from "@/components/ui/input";
+import ImageCropDialog from "./ImageCropDialog";
+import { useAvatarMutations } from "@/hooks/use-avatar";
+import { Banner } from "@/components/ui/banner";
+
+export default function AvatarUpload({ user }: { user: User | null }) {
+  const queryClient = useQueryClient();
+  const [clientSideUploadError, setClientSideUploadError] = useState<
+    string | null
+  >(null);
+  const [optimisticAvatarUrl, setOptimisticAvatarUrl] = useState<string | null>(
+    null,
+  );
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const [imageToCropSrc, setImageToCropSrc] = useState<string | null>(null);
+  const [cropModalOpen, setCropModalOpen] = useState(false);
+  const [originalFile, setOriginalFile] = useState<File | null>(null);
+
+  const { data: imageUrl, isLoading: isImageUrlLoading } = useQuery({
+    queryKey: ["avatar-url", user?.id, user?.image],
+    queryFn: async () => {
+      if (!user?.image) {
+        return null;
+      }
+      const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/user/get-avatar`);
+      url.searchParams.append("key", user.image);
+
+      const presignedUrlResponse = await fetch(url.toString(), {
+        method: "GET",
+        headers: { "Content-Type": "application/json" },
+        credentials: "include",
+      });
+
+      if (!presignedUrlResponse.ok) {
+        console.error(
+          "Failed to fetch avatar URL:",
+          presignedUrlResponse.status,
+        );
+        return null;
+      }
+      const presignedUrlData = await presignedUrlResponse.json();
+      return presignedUrlData.data || null;
+    },
+    enabled: !!user && !!user.image && !optimisticAvatarUrl,
+    staleTime: 5 * 60 * 1000,
+    retry: 2,
+  });
+
+  const { uploadAvatarMutation, removeAvatarMutation } = useAvatarMutations({
+    user,
+    queryClient,
+    setClientSideUploadError,
+    setOptimisticAvatarUrl,
+    fileInputRef,
+    setCropModalOpen,
+  });
+
+  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (file) {
+      if (file.size > 5 * 1024 * 1024) {
+        setClientSideUploadError("File is too large (max 5MB).");
+        uploadAvatarMutation.reset();
+        return;
+      }
+      if (
+        !["image/jpeg", "image/png", "image/webp", "image/jpg"].includes(
+          file.type,
+        )
+      ) {
+        setClientSideUploadError(
+          "Invalid file type. Please use JPG, PNG, or WEBP.",
+        );
+        uploadAvatarMutation.reset();
+        return;
+      }
+      setClientSideUploadError(null);
+      setOriginalFile(file);
+      setImageToCropSrc(URL.createObjectURL(file));
+      setCropModalOpen(true);
+    }
+  };
+
+  const handleUpdateAvatarClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleRemoveAvatarClick = () => {
+    if (user?.image || optimisticAvatarUrl) {
+      removeAvatarMutation.mutate();
+    } else {
+      setClientSideUploadError("No avatar to remove.");
+    }
+  };
+
+  const handleCropConfirm = (croppedImageFile: File) => {
+    setCropModalOpen(false);
+    uploadAvatarMutation.mutate(croppedImageFile);
+  };
+
+  const handleCropCancel = () => {
+    setCropModalOpen(false);
+    if (imageToCropSrc) {
+      URL.revokeObjectURL(imageToCropSrc);
+      setImageToCropSrc(null);
+    }
+    if (fileInputRef.current) {
+      fileInputRef.current.value = "";
+    }
+    setOriginalFile(null);
+  };
+
+  useEffect(() => {
+    return () => {
+      // biome-ignore lint:
+      if (optimisticAvatarUrl && optimisticAvatarUrl.startsWith("blob:")) {
+        URL.revokeObjectURL(optimisticAvatarUrl);
+      }
+      if (imageToCropSrc) {
+        URL.revokeObjectURL(imageToCropSrc);
+      }
+    };
+  }, [optimisticAvatarUrl, imageToCropSrc]);
+
+  const displayImageSrc = optimisticAvatarUrl || imageUrl;
+  const showRemoveOption = !!(user?.image || optimisticAvatarUrl);
+  const isActionPending =
+    uploadAvatarMutation.isPending ||
+    removeAvatarMutation.isPending ||
+    isImageUrlLoading;
+
+  return (
+    <div className="flex flex-col items-center gap-3 pt-6">
+      <AvatarDisplay
+        displayImageSrc={displayImageSrc}
+        user={user}
+        isActionPending={isActionPending}
+        isImageUrlLoading={isImageUrlLoading}
+        optimisticAvatarUrl={optimisticAvatarUrl}
+        uploadAvatarMutation={uploadAvatarMutation}
+        showRemoveOption={showRemoveOption}
+        onUpdateAvatarClick={handleUpdateAvatarClick}
+        onRemoveAvatarClick={handleRemoveAvatarClick}
+      />
+
+      <Input
+        id="avatarUpload"
+        type="file"
+        ref={fileInputRef}
+        className="hidden"
+        accept="image/png, image/jpeg, image/webp, image/jpg"
+        onChange={handleFileChange}
+        disabled={uploadAvatarMutation.isPending || cropModalOpen}
+      />
+
+      <ImageCropDialog
+        open={cropModalOpen}
+        imageToCropSrc={imageToCropSrc}
+        originalFile={originalFile}
+        onCropConfirm={handleCropConfirm}
+        onCropCancel={handleCropCancel}
+        uploadAvatarMutation={uploadAvatarMutation}
+        setClientSideUploadError={setClientSideUploadError}
+      />
+
+      {(uploadAvatarMutation.isError ||
+        removeAvatarMutation.isError ||
+        !!clientSideUploadError) && (
+        <Banner
+          variant="error"
+          className="w-full max-w-xs text-sm text-center mt-2"
+        >
+          {clientSideUploadError ||
+            uploadAvatarMutation.error?.message ||
+            removeAvatarMutation.error?.message ||
+            "An error occurred."}
+        </Banner>
+      )}
+    </div>
+  );
+}
diff --git a/apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx b/apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx
new file mode 100644
index 0000000..20e3e02
--- /dev/null
+++ b/apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx
@@ -0,0 +1,218 @@
+import { useState, useRef } from "react";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Loading01Icon } from "@hugeicons/core-free-icons";
+import ReactCrop, {
+  type Crop,
+  type PixelCrop,
+  centerCrop,
+  makeAspectCrop,
+} from "react-image-crop";
+import "react-image-crop/dist/ReactCrop.css";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import type { UseMutationResult } from "@tanstack/react-query";
+
+const OUTPUT_AVATAR_SIZE = 256;
+
+async function getCroppedBlob(
+  imageElement: HTMLImageElement,
+  crop: PixelCrop,
+  fileName: string,
+): Promise<File | null> {
+  const canvas = document.createElement("canvas");
+  canvas.width = OUTPUT_AVATAR_SIZE;
+  canvas.height = OUTPUT_AVATAR_SIZE;
+  const ctx = canvas.getContext("2d");
+
+  if (!ctx) {
+    console.error("Failed to get 2D context");
+    return null;
+  }
+
+  const scaleX = imageElement.naturalWidth / imageElement.width;
+  const scaleY = imageElement.naturalHeight / imageElement.height;
+
+  const cropX = crop.x * scaleX;
+  const cropY = crop.y * scaleY;
+  const cropWidth = crop.width * scaleX;
+  const cropHeight = crop.height * scaleY;
+
+  ctx.beginPath();
+  ctx.arc(
+    OUTPUT_AVATAR_SIZE / 2,
+    OUTPUT_AVATAR_SIZE / 2,
+    OUTPUT_AVATAR_SIZE / 2,
+    0,
+    Math.PI * 2,
+    true,
+  );
+  ctx.closePath();
+  ctx.clip();
+
+  ctx.drawImage(
+    imageElement,
+    cropX,
+    cropY,
+    cropWidth,
+    cropHeight,
+    0,
+    0,
+    OUTPUT_AVATAR_SIZE,
+    OUTPUT_AVATAR_SIZE,
+  );
+
+  return new Promise((resolve, reject) => {
+    canvas.toBlob(
+      (blob) => {
+        if (!blob) {
+          console.error("Canvas to Blob conversion failed.");
+          reject(new Error("Canvas to Blob conversion failed."));
+          return;
+        }
+
+        const outputType = "image/png";
+        const outputFileName = `${fileName.replace(/\.[^/.]+$/, "")}.png`;
+        resolve(new File([blob], outputFileName, { type: outputType }));
+      },
+      "image/png",
+      0.95,
+    );
+  });
+}
+
+interface ImageCropModalProps {
+  open: boolean;
+  imageToCropSrc: string | null;
+  originalFile: File | null;
+  onCropConfirm: (croppedFile: File) => void;
+  onCropCancel: () => void;
+  uploadAvatarMutation: UseMutationResult<unknown, Error, File, unknown>;
+  setClientSideUploadError: (error: string | null) => void;
+}
+
+export default function ImageCropDialog({
+  open,
+  imageToCropSrc,
+  originalFile,
+  onCropConfirm,
+  onCropCancel,
+  uploadAvatarMutation,
+  setClientSideUploadError,
+}: ImageCropModalProps) {
+  const [crop, setCrop] = useState<Crop | undefined>();
+  const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
+  const imgRef = useRef<HTMLImageElement>(null);
+
+  const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
+    const { width, height } = e.currentTarget;
+
+    const crop = makeAspectCrop(
+      {
+        unit: "%",
+        width: 80,
+      },
+      1,
+      width,
+      height,
+    );
+    const centeredCrop = centerCrop(crop, width, height);
+    setCrop(centeredCrop);
+    setCompletedCrop({
+      ...centeredCrop,
+      unit: "px",
+      x: (centeredCrop.x / 100) * width,
+      y: (centeredCrop.y / 100) * height,
+      width: (centeredCrop.width / 100) * width,
+      height: (centeredCrop.height / 100) * height,
+    });
+  };
+
+  const handleCropConfirm = async () => {
+    if (!completedCrop || !imgRef.current || !originalFile) {
+      setClientSideUploadError("Cropping failed. Please try again.");
+      return;
+    }
+    try {
+      const croppedImageFile = await getCroppedBlob(
+        imgRef.current,
+        completedCrop,
+        originalFile.name,
+      );
+      if (croppedImageFile) {
+        onCropConfirm(croppedImageFile);
+      } else {
+        setClientSideUploadError("Failed to process cropped image.");
+      }
+    } catch (_error) {
+      setClientSideUploadError("Error during cropping.");
+    }
+  };
+
+  if (!open || !imageToCropSrc) return null;
+
+  return (
+    <Dialog
+      open={open}
+      onOpenChange={(open) => {
+        if (!open) onCropCancel();
+      }}
+    >
+      <DialogContent className="max-w-md">
+        <DialogHeader>
+          <DialogTitle>Crop your new avatar</DialogTitle>
+        </DialogHeader>
+        <div className="my-4 flex justify-center items-center max-h-[60vh] overflow-hidden">
+          <ReactCrop
+            crop={crop}
+            onChange={(_, percentCrop) => setCrop(percentCrop)}
+            onComplete={(c) => setCompletedCrop(c)}
+            aspect={1}
+            circularCrop
+            minWidth={50}
+            minHeight={50}
+            keepSelection
+          >
+            <img
+              ref={imgRef}
+              src={imageToCropSrc}
+              alt="Crop preview"
+              onLoad={onImageLoad}
+              style={{ maxHeight: "50vh", objectFit: "contain" }}
+            />
+          </ReactCrop>
+        </div>
+        <DialogFooter className="gap-2 sm:justify-end">
+          <Button
+            size="sm"
+            variant="outline"
+            onClick={onCropCancel}
+            disabled={uploadAvatarMutation.isPending}
+          >
+            Cancel
+          </Button>
+          <Button
+            size="sm"
+            onClick={handleCropConfirm}
+            disabled={!completedCrop || uploadAvatarMutation.isPending}
+          >
+            {uploadAvatarMutation.isPending && (
+              <HugeiconsIcon
+                icon={Loading01Icon}
+                size={18}
+                className="mr-1 animate-spin"
+              />
+            )}
+            Crop & Upload
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/apps/frontend/src/components/sidebar/UserButton.tsx b/apps/frontend/src/components/sidebar/UserButton.tsx
index a5b0650..7293b4d 100644
--- a/apps/frontend/src/components/sidebar/UserButton.tsx
+++ b/apps/frontend/src/components/sidebar/UserButton.tsx
@@ -68,6 +68,7 @@ export default function UserButton() {
               <AvatarImage
                 src={imageUrl ?? ""}
                 alt={`${session?.user.name} profile picture`}
+                className="object-cover w-full h-full"
               />
               <AvatarFallback className="text-foreground/70">
                 <HugeiconsIcon
diff --git a/apps/frontend/src/hooks/use-avatar.tsx b/apps/frontend/src/hooks/use-avatar.tsx
new file mode 100644
index 0000000..8b0e652
--- /dev/null
+++ b/apps/frontend/src/hooks/use-avatar.tsx
@@ -0,0 +1,206 @@
+import { useMutation } from "@tanstack/react-query";
+import { auth } from "@/auth/client";
+import type { User } from "better-auth/types";
+import type { QueryClient } from "@tanstack/react-query";
+
+interface UseAvatarMutationsProps {
+  user: User | null;
+  queryClient: QueryClient;
+  setClientSideUploadError: (error: string | null) => void;
+  setOptimisticAvatarUrl: (url: string | null) => void;
+  fileInputRef: React.RefObject<HTMLInputElement | null>;
+  setCropModalOpen: (open: boolean) => void;
+}
+
+export function useAvatarMutations({
+  user,
+  queryClient,
+  setClientSideUploadError,
+  setOptimisticAvatarUrl,
+  fileInputRef,
+  setCropModalOpen,
+}: UseAvatarMutationsProps) {
+  const deleteS3ObjectMutation = useMutation({
+    mutationFn: async (key: string) => {
+      const url = new URL(
+        `${process.env.NEXT_PUBLIC_API_URL}/user/delete-avatar`,
+      );
+      url.searchParams.append("key", key);
+      const response = await fetch(url.toString(), {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+        credentials: "include",
+      });
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => ({}));
+        throw new Error(
+          errorData.message || "Failed to delete avatar from storage.",
+        );
+      }
+      return response.json();
+    },
+  });
+
+  const uploadAvatarMutation = useMutation({
+    mutationFn: async (file: File) => {
+      setClientSideUploadError(null);
+      const fileName = encodeURIComponent(file.name);
+      const type = file.type;
+
+      const presignedUrlResponse = await fetch(
+        `${process.env.NEXT_PUBLIC_API_URL}/user/upload-avatar`,
+        {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          credentials: "include",
+          body: JSON.stringify({ fileName, type }),
+        },
+      );
+      if (!presignedUrlResponse.ok) {
+        const errorData = await presignedUrlResponse.json().catch(() => ({}));
+        throw new Error(errorData.message || "Failed to get upload URL.");
+      }
+      const { url: S3UploadUrl, key: newImageKey } = (
+        await presignedUrlResponse.json()
+      ).data;
+
+      const uploadResponse = await fetch(S3UploadUrl, {
+        method: "PUT",
+        headers: { "Content-Type": type },
+        body: file,
+      });
+      if (!uploadResponse.ok) {
+        throw new Error("File upload to storage failed.");
+      }
+
+      const oldImageKey = user?.image;
+      if (oldImageKey && oldImageKey !== newImageKey) {
+        deleteS3ObjectMutation.mutate(oldImageKey, {
+          onError: (error) =>
+            console.error(
+              "Failed to delete old S3 object during update:",
+              error,
+            ),
+        });
+      }
+
+      const updateUserResponse = await auth.updateUser({ image: newImageKey });
+      if (updateUserResponse.error) {
+        try {
+          await deleteS3ObjectMutation.mutateAsync(newImageKey);
+        } catch (cleanupError) {
+          console.error(
+            "Failed to clean up newly uploaded avatar after DB update failure:",
+            cleanupError,
+          );
+        }
+        throw new Error(
+          updateUserResponse.error.message || "Failed to update user profile.",
+        );
+      }
+      return updateUserResponse.data;
+    },
+    onMutate: async (file: File) => {
+      const localUrl = URL.createObjectURL(file);
+      setOptimisticAvatarUrl(localUrl);
+
+      await queryClient.cancelQueries({
+        queryKey: ["avatar-url", user?.id, user?.image],
+      });
+      const previousImageUrl = queryClient.getQueryData([
+        "avatar-url",
+        user?.id,
+        user?.image,
+      ]);
+      queryClient.setQueryData(["avatar-url", user?.id, user?.image], localUrl);
+      return { localUrl, previousImageUrl };
+    },
+    onSuccess: (_data, _variables, _context) => {
+      queryClient.invalidateQueries({ queryKey: ["currentSession"] });
+      queryClient.invalidateQueries({ queryKey: ["avatar-url"], exact: false });
+    },
+    onError: (error, _variables, context) => {
+      setClientSideUploadError(error.message || "Avatar upload failed.");
+      if (context?.localUrl) {
+        URL.revokeObjectURL(context.localUrl);
+      }
+      if (context?.previousImageUrl) {
+        queryClient.setQueryData(
+          ["avatar-url", user?.id, user?.image],
+          context.previousImageUrl,
+        );
+      }
+      setOptimisticAvatarUrl(null);
+    },
+    onSettled: (_data, _error, _variables, context) => {
+      if (context?.localUrl) {
+        URL.revokeObjectURL(context.localUrl);
+      }
+      // biome-ignore lint: shut up biome
+      if (fileInputRef && fileInputRef.current) {
+        fileInputRef.current.value = "";
+      }
+      setCropModalOpen(false);
+      setOptimisticAvatarUrl(null);
+    },
+  });
+
+  const removeAvatarMutation = useMutation({
+    mutationFn: async () => {
+      if (!user?.image) {
+        throw new Error("No avatar to remove.");
+      }
+      const imageKeyToRemove = user.image;
+
+      const updateUserResponse = await auth.updateUser({ image: null });
+      if (updateUserResponse.error) {
+        throw new Error(
+          updateUserResponse.error.message ||
+            "Failed to update user profile before removing avatar.",
+        );
+      }
+      try {
+        await deleteS3ObjectMutation.mutateAsync(imageKeyToRemove);
+      } catch (s3Error) {
+        console.error(
+          "Failed to delete avatar from S3, but user record was updated.",
+          s3Error,
+        );
+      }
+      return updateUserResponse.data;
+    },
+    onMutate: async () => {
+      setOptimisticAvatarUrl(null);
+
+      await queryClient.cancelQueries({
+        queryKey: ["avatar-url", user?.id, user?.image],
+      });
+      const previousImageUrl = queryClient.getQueryData([
+        "avatar-url",
+        user?.id,
+        user?.image,
+      ]);
+      queryClient.setQueryData(["avatar-url", user?.id, user?.image], null);
+      return { previousImageUrl };
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["currentSession"] });
+      queryClient.invalidateQueries({ queryKey: ["avatar-url"] });
+      setClientSideUploadError(null);
+    },
+    onError: (error, _variables, context) => {
+      setClientSideUploadError(error.message || "Failed to remove avatar.");
+      if (context?.previousImageUrl) {
+        queryClient.setQueryData(
+          ["avatar-url", user?.id, user?.image],
+          context.previousImageUrl,
+        );
+      }
+    },
+  });
+
+  return {
+    uploadAvatarMutation,
+    removeAvatarMutation,
+  };
+}
diff --git a/package.json b/package.json
index 8f84d16..d6ef562 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
     "@hugeicons/core-free-icons": "^1.0.14",
     "@hugeicons/react": "^1.0.5",
     "maplibre-gl": "^5.5.0",
+    "react-image-crop": "^11.0.10",
     "react-map-gl": "^8.0.4"
   }
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d75f905..9fab0ba 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
       maplibre-gl:
         specifier: ^5.5.0
         version: 5.5.0
+      react-image-crop:
+        specifier: ^11.0.10
+        version: 11.0.10(react@19.0.0)
       react-map-gl:
         specifier: ^8.0.4
         version: 8.0.4(maplibre-gl@5.5.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -3076,6 +3079,11 @@ packages:
     peerDependencies:
       react: ^19.0.0
 
+  react-image-crop@11.0.10:
+    resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
+    peerDependencies:
+      react: '>=16.13.1'
+
   react-intl@7.1.6:
     resolution: {integrity: sha512-TgyzN0GJDILl4EIjbBWJddp+zRMx7IrYXeeUBwDisXfuglJaafAUVYYTx9wTWMRJrm2LubaCeH3L68mIPTmUYA==}
     peerDependencies:
@@ -6521,6 +6529,10 @@ snapshots:
       react: 19.0.0
       scheduler: 0.25.0
 
+  react-image-crop@11.0.10(react@19.0.0):
+    dependencies:
+      react: 19.0.0
+
   react-intl@7.1.6(react@19.0.0)(typescript@5.8.2):
     dependencies:
       '@formatjs/ecma402-abstract': 2.3.3


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 6**

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: ASSERTIVE**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between e267a8f0bec72cfa0b1271a7dc8de3a5c2ca900d and 384ff83ad4bb7201743cf7d04aa9ec7a6ffeef42.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `pnpm-lock.yaml` is excluded by `!**/pnpm-lock.yaml`

</details>

<details>
<summary>📒 Files selected for processing (10)</summary>

* `apps/frontend/src/components/settings/AvatarUpload.tsx` (0 hunks)
* `apps/frontend/src/components/settings/DeleteAccountDialog.tsx` (1 hunks)
* `apps/frontend/src/components/settings/ProfileTab.tsx` (3 hunks)
* `apps/frontend/src/components/settings/SettingsDialog.tsx` (1 hunks)
* `apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx` (1 hunks)
* `apps/frontend/src/components/settings/avatar/AvatarUpload.tsx` (1 hunks)
* `apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx` (1 hunks)
* `apps/frontend/src/components/sidebar/UserButton.tsx` (1 hunks)
* `apps/frontend/src/hooks/use-avatar.tsx` (1 hunks)
* `package.json` (1 hunks)

</details>

<details>
<summary>💤 Files with no reviewable changes (1)</summary>

* apps/frontend/src/components/settings/AvatarUpload.tsx

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (3)</summary>

<details>
<summary>apps/frontend/src/components/settings/DeleteAccountDialog.tsx (1)</summary><blockquote>

<details>
<summary>apps/frontend/src/components/ui/dialog.tsx (4)</summary>

* `DialogContent` (126-126)
* `DialogHeader` (127-127)
* `DialogTitle` (129-129)
* `DialogDescription` (130-130)

</details>

</blockquote></details>
<details>
<summary>apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx (2)</summary><blockquote>

<details>
<summary>apps/frontend/src/components/ui/dialog.tsx (5)</summary>

* `Dialog` (121-121)
* `DialogContent` (126-126)
* `DialogHeader` (127-127)
* `DialogTitle` (129-129)
* `DialogFooter` (128-128)

</details>
<details>
<summary>apps/frontend/src/components/ui/button.tsx (1)</summary>

* `Button` (66-66)

</details>

</blockquote></details>
<details>
<summary>apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx (3)</summary><blockquote>

<details>
<summary>apps/frontend/src/components/ui/dropdown-menu.tsx (5)</summary>

* `DropdownMenu` (243-243)
* `DropdownMenuTrigger` (245-245)
* `DropdownMenuContent` (246-246)
* `DropdownMenuItem` (249-249)
* `DropdownMenuSeparator` (253-253)

</details>
<details>
<summary>apps/frontend/src/components/ui/avatar.tsx (3)</summary>

* `Avatar` (53-53)
* `AvatarImage` (53-53)
* `AvatarFallback` (53-53)

</details>
<details>
<summary>apps/frontend/src/lib/utils.ts (1)</summary>

* `cn` (4-6)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (19)</summary><blockquote>

<details>
<summary>package.json (1)</summary>

`49-49`: <details>
<summary>✅ Verification successful</summary>

**Verify the latest version and security status of react-image-crop.**

The added dependency supports the new avatar cropping functionality. Please ensure you're using the latest stable version and check for any security advisories.

---

🌐 Web query:

```web
What is the latest version of react-image-crop npm package and are there any known security vulnerabilities?
```

💡 Result:

The latest version of the react-image-crop npm package is 11.0.10, which was released on April 14, 2025, about a month ago from the current date[1][5]. This version includes a fix for an issue related to Vite 6 changing the CSS file name from `ReactCrop.css` to `index.css`[2].

The package is a responsive image cropping tool for React with several features:
- Touch-enabled functionality
- Free-form or fixed aspect crops
- Fully keyboard accessible
- No dependencies (small footprint of <5KB gzip)
- Min/max crop size capabilities
- Can crop any content, not just images[1]

Regarding security vulnerabilities, there is no direct information about any known vulnerabilities specifically for the react-image-crop package in the search results. While there is vulnerability information for a different package called "react-image-cropping" which has no direct vulnerabilities found[6], this is not the same package.

The react-image-crop package is actively maintained, with recent updates addressing bugs and improvements. The most recent significant version change was version 11.0.0, which included changes to the way crop shadows are implemented and switched from SASS variables to CSS Variables[2].

The package is compatible with React versions 16.13.1 and higher as specified in its peer dependencies[5], and works with all modern browsers but does not support Internet Explorer.

Citations:

- 1: https://www.npmjs.com/package/react-image-crop
- 2: https://github.com/sekoyo/react-image-crop/releases
- 3: https://codesandbox.io/examples/package/react-image-crop
- 4: https://www.jsdelivr.com/package/npm/hayer-react-image-crop
- 5: https://github.com/sekoyo/react-image-crop/blob/master/package.json
- 6: https://security.snyk.io/package/npm/react-image-cropping
- 7: https://nextjs.org/docs/pages/api-reference/components/image
- 8: https://www.npmjs.com/package/react-easy-crop
- 9: https://www.jsdelivr.com/package/npm/@bearcanrun/react-image-cropper
- 10: https://github.com/ivpusic/react-native-image-crop-picker

---



</details>

**Confirmed: react-image-crop@11.0.10 is the latest stable release (April 14 2025) with no known security vulnerabilities.**   
No further action required.

</details>
<details>
<summary>apps/frontend/src/components/settings/DeleteAccountDialog.tsx (2)</summary>

`67-67`: **Excellent responsive design improvements.**

The updated DialogContent classes provide better mobile experience with full viewport height (`h-[100dvh]`) on small screens and constrained size (`h-fit max-w-[30rem]`) on larger screens.

---

`69-75`: **Improved typography and spacing for better UX.**

The responsive font weights (`md:font-semibold` vs `font-bold`) and adjusted margins create better visual hierarchy across different screen sizes.

</details>
<details>
<summary>apps/frontend/src/components/sidebar/UserButton.tsx (1)</summary>

`71-71`: **Good styling improvement for avatar display.**

The added classes (`object-cover w-full h-full`) ensure consistent avatar presentation by making the image cover the entire container while maintaining aspect ratio.

</details>
<details>
<summary>apps/frontend/src/components/settings/ProfileTab.tsx (2)</summary>

`8-8`: **Good modular restructuring.**

The updated import path reflects the new avatar component organization under the `avatar` subdirectory, improving code modularity and maintainability.

---

`97-97`: **Improved readability with larger font size.**

Changing from `text-xs` to `text-base` enhances readability and accessibility for the input fields displaying user name and email.




Also applies to: 119-119

</details>
<details>
<summary>apps/frontend/src/components/settings/SettingsDialog.tsx (4)</summary>

`74-74`: **Enhanced mobile spacing for better UX.**

The addition of `mt-6` provides proper vertical spacing on mobile devices while maintaining the existing desktop layout with `md:mt-0`.

---

`79-79`: **Improved mobile typography for tab triggers.**

The larger font size (`text-xl`) on mobile improves readability and touch target accessibility, while reverting to `md:text-base` on larger screens maintains appropriate sizing.




Also applies to: 92-92

---

`86-86`: **Better icon sizing for mobile interaction.**

Larger icons (`size-8`) on mobile improve touch targets and visual hierarchy, with appropriate sizing (`sm:size-6`) for larger screens.




Also applies to: 99-99

---

`112-112`: **Consistent content spacing across breakpoints.**

The increased top margin (`mt-12`) on mobile provides better visual separation, while `md:mt-0` maintains the compact desktop layout.




Also applies to: 118-118

</details>
<details>
<summary>apps/frontend/src/components/settings/avatar/AvatarUpload.tsx (2)</summary>

`26-54`: **Good implementation of React Query with proper caching strategy.**

The query configuration is well-structured with appropriate caching (5 minutes) and retry logic. The conditional enabling based on user state and optimistic updates prevents unnecessary requests.

---

`120-130`: **Proper memory management with object URL cleanup.**

The useEffect cleanup properly revokes object URLs to prevent memory leaks. The dependency array correctly includes both URLs that need cleanup.

</details>
<details>
<summary>apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx (3)</summary>

`20-30`: **Well-defined interface with comprehensive prop types.**

The interface clearly defines all necessary props with proper TypeScript types, including the mutation result type from React Query.

---

`44-91`: **Good implementation of interactive avatar display with proper states.**

The component correctly handles different states (loading, pending actions, hover effects) and provides clear visual feedback to users. The overlay logic properly shows loading spinners and edit indicators.

---

`114-114`: <details>
<summary>✅ Verification successful</summary>

**Verify the destructive variant support in DropdownMenuItem.**

Ensure that the `variant="destructive"` prop is supported by your DropdownMenuItem component, as this might not be a standard prop.

---

🏁 Script executed:

```shell
#!/bin/bash
# Description: Check if DropdownMenuItem supports variant prop

# Search for DropdownMenuItem interface or type definition
ast-grep --pattern $'interface $_Props {
  $$$
  variant?: $_;
  $$$
}'

# Also search for variant prop usage in DropdownMenuItem
rg -A 5 -B 5 "variant.*destructive" --type tsx --type ts
```

Length of output: 159

---

🏁 Script executed:

```shell
#!/bin/bash
set -e

echo "1) Finding where DropdownMenuItem is defined…"
rg -nE "export (function|const) DropdownMenuItem" -g '*.tsx' -g '*.ts'

echo
echo "2) Looking for its props interface or type alias…"
rg -nE "(interface|type)\s+[A-Za-z0-9_]*DropdownMenuItemProps" -g '*.tsx' -g '*.ts'
```

Length of output: 579

---

🏁 Script executed:

```shell
#!/bin/bash
set -e

echo "1) Locating all occurrences of 'DropdownMenuItem' in the codebase..."
rg -n "DropdownMenuItem" -g '*.tsx' -g '*.ts' || true

echo
echo "2) Searching for its props definition (type or interface)..."
rg -nE "type\s+DropdownMenuItemProps" -g '*.tsx' -g '*.ts' || true
rg -nE "interface\s+DropdownMenuItemProps" -g '*.tsx' -g '*.ts' || true

echo
echo "3) If a props file is found, inspect it for 'variant'..."
props_file=$(rg -lE "type\s+DropdownMenuItemProps|interface\s+DropdownMenuItemProps" -g '*.tsx' -g '*.ts' | head -n1)
if [ -n "$props_file" ]; then
  echo "Inspecting $props_file for 'variant' usage:"
  rg -n "variant" "$props_file" || echo "  ↳ No 'variant' prop defined in $props_file"
else
  echo "No props definition found; unable to verify 'variant' support automatically."
fi
```

Length of output: 3205

---

🏁 Script executed:

```shell
#!/bin/bash
set -e

COMPONENT_PATH="apps/frontend/src/components/ui/dropdown-menu.tsx"

echo "1) Extracting DropdownMenuItem definition and props destructuring:"
rg -A5 -B5 "function DropdownMenuItem" -g "$COMPONENT_PATH" || true

echo
echo "2) Checking for 'variant' usage inside DropdownMenuItem:"
rg -n "variant" -g "$COMPONENT_PATH" || echo "No 'variant' references found in DropdownMenuItem"
```

Length of output: 2195

---



</details>

**Resolved: `variant="destructive"` is supported by DropdownMenuItem**  
No changes required—`DropdownMenuItem` defines  
```ts
variant?: "default" | "destructive"
```  
and applies `data-[variant=destructive]…` styles in `apps/frontend/src/components/ui/dropdown-menu.tsx`.

</details>
<details>
<summary>apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx (1)</summary>

`24-88`: **Robust canvas-based cropping implementation with good error handling.**

The `getCroppedBlob` function properly handles:
- Canvas context validation
- Scale calculations for different image sizes
- Circular clipping path
- Error handling for blob conversion

The quality setting of 0.95 for PNG compression is appropriate.

</details>
<details>
<summary>apps/frontend/src/hooks/use-avatar.tsx (3)</summary>

`23-42`: **Good S3 deletion mutation with proper error handling.**

The mutation correctly handles response parsing errors and provides meaningful error messages.

---

`103-116`: **Robust optimistic update implementation with proper context.**

The onMutate function correctly cancels existing queries and sets up rollback context. Good use of React Query's optimistic update pattern.

---

`162-169`: **Good error isolation between S3 and database operations.**

The remove mutation correctly prioritizes the database update and treats S3 deletion as secondary, logging but not failing the operation if S3 deletion fails.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T03:23:52Z

Review comments:
diff_hunk: @@ -0,0 +1,188 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import type { User } from "better-auth/types";
+import { type ChangeEvent, useEffect, useRef, useState } from "react";
+import AvatarDisplay from "./AvatarDisplay";
+import { Input } from "@/components/ui/input";
+import ImageCropDialog from "./ImageCropDialog";
+import { useAvatarMutations } from "@/hooks/use-avatar";
+import { Banner } from "@/components/ui/banner";
+
+export default function AvatarUpload({ user }: { user: User | null }) {
+  const queryClient = useQueryClient();
+  const [clientSideUploadError, setClientSideUploadError] = useState<
+    string | null
+  >(null);
+  const [optimisticAvatarUrl, setOptimisticAvatarUrl] = useState<string | null>(
+    null,
+  );
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const [imageToCropSrc, setImageToCropSrc] = useState<string | null>(null);
+  const [cropModalOpen, setCropModalOpen] = useState(false);
+  const [originalFile, setOriginalFile] = useState<File | null>(null);
+
+  const { data: imageUrl, isLoading: isImageUrlLoading } = useQuery({
+    queryKey: ["avatar-url", user?.id, user?.image],
+    queryFn: async () => {
+      if (!user?.image) {
+        return null;
+      }
+      const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/user/get-avatar`);
+      url.searchParams.append("key", user.image);
+
+      const presignedUrlResponse = await fetch(url.toString(), {
+        method: "GET",
+        headers: { "Content-Type": "application/json" },
+        credentials: "include",
+      });
+
+      if (!presignedUrlResponse.ok) {
+        console.error(
+          "Failed to fetch avatar URL:",
+          presignedUrlResponse.status,
+        );
+        return null;
+      }
+      const presignedUrlData = await presignedUrlResponse.json();
+      return presignedUrlData.data || null;
+    },
+    enabled: !!user && !!user.image && !optimisticAvatarUrl,
+    staleTime: 5 * 60 * 1000,
+    retry: 2,
+  });
+
+  const { uploadAvatarMutation, removeAvatarMutation } = useAvatarMutations({
+    user,
+    queryClient,
+    setClientSideUploadError,
+    setOptimisticAvatarUrl,
+    fileInputRef,
+    setCropModalOpen,
+  });
+
+  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (file) {
+      if (file.size > 5 * 1024 * 1024) {
+        setClientSideUploadError("File is too large (max 5MB).");
+        uploadAvatarMutation.reset();
+        return;
+      }
+      if (
+        !["image/jpeg", "image/png", "image/webp", "image/jpg"].includes(
+          file.type,
+        )
+      ) {
+        setClientSideUploadError(
+          "Invalid file type. Please use JPG, PNG, or WEBP.",
+        );
+        uploadAvatarMutation.reset();
+        return;
+      }
+      setClientSideUploadError(null);
+      setOriginalFile(file);
+      setImageToCropSrc(URL.createObjectURL(file));
+      setCropModalOpen(true);
+    }
+  };
path: apps/frontend/src/components/settings/avatar/AvatarUpload.tsx
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**File validation logic is correct but could be enhanced with MIME type verification.**

The file size and type validation is good, but consider adding actual MIME type detection beyond just checking the `file.type` property, as this can be spoofed.


Consider adding server-side MIME type validation or using a library like `file-type` for more robust type checking:

```diff
  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (file) {
+     // Additional MIME type verification could be added here
      if (file.size > 5 * 1024 * 1024) {
        setClientSideUploadError("File is too large (max 5MB).");
        uploadAvatarMutation.reset();
        return;
      }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (file) {
      // Additional MIME type verification could be added here
      if (file.size > 5 * 1024 * 1024) {
        setClientSideUploadError("File is too large (max 5MB).");
        uploadAvatarMutation.reset();
        return;
      }
      if (
        !["image/jpeg", "image/png", "image/webp", "image/jpg"].includes(
          file.type,
        )
      ) {
        setClientSideUploadError(
          "Invalid file type. Please use JPG, PNG, or WEBP.",
        );
        uploadAvatarMutation.reset();
        return;
      }
      setClientSideUploadError(null);
      setOriginalFile(file);
      setImageToCropSrc(URL.createObjectURL(file));
      setCropModalOpen(true);
    }
  };
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apps/frontend/src/components/settings/avatar/AvatarUpload.tsx around lines 65
to 89, the current file type validation relies solely on the file.type property,
which can be spoofed. To enhance security, integrate a MIME type detection
library such as 'file-type' to analyze the file's binary signature for more
reliable type verification before proceeding with the upload. Additionally,
ensure server-side validation of the MIME type to prevent malicious uploads.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T03:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,188 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import type { User } from "better-auth/types";
+import { type ChangeEvent, useEffect, useRef, useState } from "react";
+import AvatarDisplay from "./AvatarDisplay";
+import { Input } from "@/components/ui/input";
+import ImageCropDialog from "./ImageCropDialog";
+import { useAvatarMutations } from "@/hooks/use-avatar";
+import { Banner } from "@/components/ui/banner";
+
+export default function AvatarUpload({ user }: { user: User | null }) {
+  const queryClient = useQueryClient();
+  const [clientSideUploadError, setClientSideUploadError] = useState<
+    string | null
+  >(null);
+  const [optimisticAvatarUrl, setOptimisticAvatarUrl] = useState<string | null>(
+    null,
+  );
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const [imageToCropSrc, setImageToCropSrc] = useState<string | null>(null);
+  const [cropModalOpen, setCropModalOpen] = useState(false);
+  const [originalFile, setOriginalFile] = useState<File | null>(null);
+
+  const { data: imageUrl, isLoading: isImageUrlLoading } = useQuery({
+    queryKey: ["avatar-url", user?.id, user?.image],
+    queryFn: async () => {
+      if (!user?.image) {
+        return null;
+      }
+      const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/user/get-avatar`);
+      url.searchParams.append("key", user.image);
+
+      const presignedUrlResponse = await fetch(url.toString(), {
+        method: "GET",
+        headers: { "Content-Type": "application/json" },
+        credentials: "include",
+      });
+
+      if (!presignedUrlResponse.ok) {
+        console.error(
+          "Failed to fetch avatar URL:",
+          presignedUrlResponse.status,
+        );
+        return null;
+      }
+      const presignedUrlData = await presignedUrlResponse.json();
+      return presignedUrlData.data || null;
+    },
+    enabled: !!user && !!user.image && !optimisticAvatarUrl,
+    staleTime: 5 * 60 * 1000,
+    retry: 2,
+  });
+
+  const { uploadAvatarMutation, removeAvatarMutation } = useAvatarMutations({
+    user,
+    queryClient,
+    setClientSideUploadError,
+    setOptimisticAvatarUrl,
+    fileInputRef,
+    setCropModalOpen,
+  });
+
+  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (file) {
+      if (file.size > 5 * 1024 * 1024) {
+        setClientSideUploadError("File is too large (max 5MB).");
+        uploadAvatarMutation.reset();
+        return;
+      }
+      if (
+        !["image/jpeg", "image/png", "image/webp", "image/jpg"].includes(
+          file.type,
+        )
+      ) {
+        setClientSideUploadError(
+          "Invalid file type. Please use JPG, PNG, or WEBP.",
+        );
+        uploadAvatarMutation.reset();
+        return;
+      }
+      setClientSideUploadError(null);
+      setOriginalFile(file);
+      setImageToCropSrc(URL.createObjectURL(file));
+      setCropModalOpen(true);
+    }
+  };
+
+  const handleUpdateAvatarClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleRemoveAvatarClick = () => {
+    if (user?.image || optimisticAvatarUrl) {
+      removeAvatarMutation.mutate();
+    } else {
+      setClientSideUploadError("No avatar to remove.");
+    }
+  };
+
+  const handleCropConfirm = (croppedImageFile: File) => {
+    setCropModalOpen(false);
+    uploadAvatarMutation.mutate(croppedImageFile);
+  };
+
+  const handleCropCancel = () => {
+    setCropModalOpen(false);
+    if (imageToCropSrc) {
+      URL.revokeObjectURL(imageToCropSrc);
+      setImageToCropSrc(null);
+    }
+    if (fileInputRef.current) {
+      fileInputRef.current.value = "";
+    }
+    setOriginalFile(null);
+  };
+
+  useEffect(() => {
+    return () => {
+      // biome-ignore lint:
+      if (optimisticAvatarUrl && optimisticAvatarUrl.startsWith("blob:")) {
+        URL.revokeObjectURL(optimisticAvatarUrl);
+      }
+      if (imageToCropSrc) {
+        URL.revokeObjectURL(imageToCropSrc);
+      }
+    };
+  }, [optimisticAvatarUrl, imageToCropSrc]);
+
+  const displayImageSrc = optimisticAvatarUrl || imageUrl;
+  const showRemoveOption = !!(user?.image || optimisticAvatarUrl);
+  const isActionPending =
+    uploadAvatarMutation.isPending ||
+    removeAvatarMutation.isPending ||
+    isImageUrlLoading;
+
+  return (
+    <div className="flex flex-col items-center gap-3 pt-6">
+      <AvatarDisplay
+        displayImageSrc={displayImageSrc}
+        user={user}
+        isActionPending={isActionPending}
+        isImageUrlLoading={isImageUrlLoading}
+        optimisticAvatarUrl={optimisticAvatarUrl}
+        uploadAvatarMutation={uploadAvatarMutation}
+        showRemoveOption={showRemoveOption}
+        onUpdateAvatarClick={handleUpdateAvatarClick}
+        onRemoveAvatarClick={handleRemoveAvatarClick}
+      />
+
+      <Input
+        id="avatarUpload"
+        type="file"
+        ref={fileInputRef}
+        className="hidden"
+        accept="image/png, image/jpeg, image/webp, image/jpg"
+        onChange={handleFileChange}
+        disabled={uploadAvatarMutation.isPending || cropModalOpen}
+      />
+
+      <ImageCropDialog
+        open={cropModalOpen}
+        imageToCropSrc={imageToCropSrc}
+        originalFile={originalFile}
+        onCropConfirm={handleCropConfirm}
+        onCropCancel={handleCropCancel}
+        uploadAvatarMutation={uploadAvatarMutation}
+        setClientSideUploadError={setClientSideUploadError}
+      />
+
+      {uploadAvatarMutation.isError ||
+        removeAvatarMutation.isError ||
+        (!!clientSideUploadError && (
+          <Banner
+            variant="error"
+            className="w-full max-w-xs text-sm text-center mt-2"
+          >
+            {clientSideUploadError ||
+              uploadAvatarMutation.error ||
+              removeAvatarMutation.error ||
+              "An error occurred."}
+          </Banner>
+        ))}
path: apps/frontend/src/components/settings/avatar/AvatarUpload.tsx
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Fix the logical OR operator in error display condition.**

The error banner condition uses logical OR (`||`) instead of logical AND (`&&`), which will always evaluate to true when there are mutation errors, even if `clientSideUploadError` is null.


Apply this diff to fix the logical condition:

```diff
-      {uploadAvatarMutation.isError ||
-        removeAvatarMutation.isError ||
-        (!!clientSideUploadError && (
+      {(uploadAvatarMutation.isError ||
+        removeAvatarMutation.isError ||
+        !!clientSideUploadError) && (
          <Banner
            variant="error"
            className="w-full max-w-xs text-sm text-center mt-2"
          >
            {clientSideUploadError ||
              uploadAvatarMutation.error ||
              removeAvatarMutation.error ||
              "An error occurred."}
          </Banner>
        ))}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
      {(uploadAvatarMutation.isError ||
        removeAvatarMutation.isError ||
        !!clientSideUploadError) && (
          <Banner
            variant="error"
            className="w-full max-w-xs text-sm text-center mt-2"
          >
            {clientSideUploadError ||
              uploadAvatarMutation.error ||
              removeAvatarMutation.error ||
              "An error occurred."}
          </Banner>
        )}
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apps/frontend/src/components/settings/avatar/AvatarUpload.tsx around lines
173 to 185, the error banner condition incorrectly uses logical OR (||) to
combine mutation error checks and clientSideUploadError, causing the banner to
show even when clientSideUploadError is null. Change the condition to use
logical AND (&&) between the mutation error checks and clientSideUploadError to
ensure the banner only displays when both mutation errors exist and
clientSideUploadError is truthy.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T03:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { useState, useRef } from "react";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Loading01Icon } from "@hugeicons/core-free-icons";
+import ReactCrop, {
+  type Crop,
+  type PixelCrop,
+  centerCrop,
+  makeAspectCrop,
+} from "react-image-crop";
+import "react-image-crop/dist/ReactCrop.css";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import type { UseMutationResult } from "@tanstack/react-query";
+
+const OUTPUT_AVATAR_SIZE = 256;
+
+async function getCroppedBlob(
+  imageElement: HTMLImageElement,
+  crop: PixelCrop,
+  fileName: string,
+): Promise<File | null> {
+  const canvas = document.createElement("canvas");
+  canvas.width = OUTPUT_AVATAR_SIZE;
+  canvas.height = OUTPUT_AVATAR_SIZE;
+  const ctx = canvas.getContext("2d");
+
+  if (!ctx) {
+    console.error("Failed to get 2D context");
+    return null;
+  }
+
+  const scaleX = imageElement.naturalWidth / imageElement.width;
+  const scaleY = imageElement.naturalHeight / imageElement.height;
+
+  const cropX = crop.x * scaleX;
+  const cropY = crop.y * scaleY;
+  const cropWidth = crop.width * scaleX;
+  const cropHeight = crop.height * scaleY;
+
+  ctx.beginPath();
+  ctx.arc(
+    OUTPUT_AVATAR_SIZE / 2,
+    OUTPUT_AVATAR_SIZE / 2,
+    OUTPUT_AVATAR_SIZE / 2,
+    0,
+    Math.PI * 2,
+    true,
+  );
+  ctx.closePath();
+  ctx.clip();
+
+  ctx.drawImage(
+    imageElement,
+    cropX,
+    cropY,
+    cropWidth,
+    cropHeight,
+    0,
+    0,
+    OUTPUT_AVATAR_SIZE,
+    OUTPUT_AVATAR_SIZE,
+  );
+
+  return new Promise((resolve, reject) => {
+    canvas.toBlob(
+      (blob) => {
+        if (!blob) {
+          console.error("Canvas to Blob conversion failed.");
+          reject(new Error("Canvas to Blob conversion failed."));
+          return;
+        }
+
+        const outputType = "image/png";
+        const outputFileName = `${fileName.replace(/\.[^/.]+$/, "")}.png`;
+        resolve(new File([blob], outputFileName, { type: outputType }));
+      },
+      "image/png",
+      0.95,
+    );
+  });
+}
+
+interface ImageCropModalProps {
+  open: boolean;
+  imageToCropSrc: string | null;
+  originalFile: File | null;
+  onCropConfirm: (croppedFile: File) => void;
+  onCropCancel: () => void;
+  uploadAvatarMutation: UseMutationResult<unknown, Error, File, unknown>;
+  setClientSideUploadError: (error: string | null) => void;
+}
+
+export default function ImageCropDialog({
+  open,
+  imageToCropSrc,
+  originalFile,
+  onCropConfirm,
+  onCropCancel,
+  uploadAvatarMutation,
+  setClientSideUploadError,
+}: ImageCropModalProps) {
+  const [crop, setCrop] = useState<Crop | undefined>();
+  const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
+  const imgRef = useRef<HTMLImageElement>(null);
+
+  const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
+    const { width, height, naturalWidth } = e.currentTarget;
+
+    const crop = makeAspectCrop(
+      {
+        unit: "%",
+        width: Math.min(
+          90,
+          (OUTPUT_AVATAR_SIZE / naturalWidth) * 100 * (naturalWidth / width),
+        ),
+      },
+      1,
+      width,
+      height,
+    );
+    const centeredCrop = centerCrop(crop, width, height);
+    setCrop(centeredCrop);
+    setCompletedCrop({
+      ...centeredCrop,
+      unit: "px",
+      x: (centeredCrop.x / 100) * width,
+      y: (centeredCrop.y / 100) * height,
+      width: (centeredCrop.width / 100) * width,
+      height: (centeredCrop.height / 100) * height,
+    });
+  };
+
+  const handleCropConfirm = async () => {
+    if (!completedCrop || !imgRef.current || !originalFile) {
+      setClientSideUploadError("Cropping failed. Please try again.");
+      return;
+    }
+    try {
+      const croppedImageFile = await getCroppedBlob(
+        imgRef.current,
+        completedCrop,
+        originalFile.name,
+      );
+      if (croppedImageFile) {
+        onCropConfirm(croppedImageFile);
+      } else {
+        setClientSideUploadError("Failed to process cropped image.");
+      }
+    } catch (_error) {
+      setClientSideUploadError("Error during cropping.");
+    }
+  };
path: apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Add validation for minimum crop size to prevent tiny avatars.**

The function doesn't validate if the crop area is large enough to produce a meaningful avatar, which could result in pixelated output.


Add minimum crop size validation:

```diff
  const handleCropConfirm = async () => {
    if (!completedCrop || !imgRef.current || !originalFile) {
      setClientSideUploadError("Cropping failed. Please try again.");
      return;
    }
+   
+   // Validate minimum crop size
+   if (completedCrop.width < 50 || completedCrop.height < 50) {
+     setClientSideUploadError("Please select a larger area to crop.");
+     return;
+   }
+   
    try {
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  const handleCropConfirm = async () => {
    if (!completedCrop || !imgRef.current || !originalFile) {
      setClientSideUploadError("Cropping failed. Please try again.");
      return;
    }

    // Validate minimum crop size
    if (completedCrop.width < 50 || completedCrop.height < 50) {
      setClientSideUploadError("Please select a larger area to crop.");
      return;
    }

    try {
      const croppedImageFile = await getCroppedBlob(
        imgRef.current,
        completedCrop,
        originalFile.name,
      );
      if (croppedImageFile) {
        onCropConfirm(croppedImageFile);
      } else {
        setClientSideUploadError("Failed to process cropped image.");
      }
    } catch (_error) {
      setClientSideUploadError("Error during cropping.");
    }
  };
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx around lines
140 to 159, the handleCropConfirm function lacks validation for the minimum crop
size, which can lead to very small, pixelated avatars. Add a check before
processing the crop to ensure the completedCrop width and height meet a defined
minimum threshold (e.g., 50x50 pixels). If the crop area is smaller than this
minimum, set a client-side upload error indicating the crop is too small and
prevent further processing.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T03:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,221 @@
+import { useState, useRef } from "react";
+
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Loading01Icon } from "@hugeicons/core-free-icons";
+import ReactCrop, {
+  type Crop,
+  type PixelCrop,
+  centerCrop,
+  makeAspectCrop,
+} from "react-image-crop";
+import "react-image-crop/dist/ReactCrop.css";
+import {
+  Dialog,
+  DialogContent,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import type { UseMutationResult } from "@tanstack/react-query";
+
+const OUTPUT_AVATAR_SIZE = 256;
+
+async function getCroppedBlob(
+  imageElement: HTMLImageElement,
+  crop: PixelCrop,
+  fileName: string,
+): Promise<File | null> {
+  const canvas = document.createElement("canvas");
+  canvas.width = OUTPUT_AVATAR_SIZE;
+  canvas.height = OUTPUT_AVATAR_SIZE;
+  const ctx = canvas.getContext("2d");
+
+  if (!ctx) {
+    console.error("Failed to get 2D context");
+    return null;
+  }
+
+  const scaleX = imageElement.naturalWidth / imageElement.width;
+  const scaleY = imageElement.naturalHeight / imageElement.height;
+
+  const cropX = crop.x * scaleX;
+  const cropY = crop.y * scaleY;
+  const cropWidth = crop.width * scaleX;
+  const cropHeight = crop.height * scaleY;
+
+  ctx.beginPath();
+  ctx.arc(
+    OUTPUT_AVATAR_SIZE / 2,
+    OUTPUT_AVATAR_SIZE / 2,
+    OUTPUT_AVATAR_SIZE / 2,
+    0,
+    Math.PI * 2,
+    true,
+  );
+  ctx.closePath();
+  ctx.clip();
+
+  ctx.drawImage(
+    imageElement,
+    cropX,
+    cropY,
+    cropWidth,
+    cropHeight,
+    0,
+    0,
+    OUTPUT_AVATAR_SIZE,
+    OUTPUT_AVATAR_SIZE,
+  );
+
+  return new Promise((resolve, reject) => {
+    canvas.toBlob(
+      (blob) => {
+        if (!blob) {
+          console.error("Canvas to Blob conversion failed.");
+          reject(new Error("Canvas to Blob conversion failed."));
+          return;
+        }
+
+        const outputType = "image/png";
+        const outputFileName = `${fileName.replace(/\.[^/.]+$/, "")}.png`;
+        resolve(new File([blob], outputFileName, { type: outputType }));
+      },
+      "image/png",
+      0.95,
+    );
+  });
+}
+
+interface ImageCropModalProps {
+  open: boolean;
+  imageToCropSrc: string | null;
+  originalFile: File | null;
+  onCropConfirm: (croppedFile: File) => void;
+  onCropCancel: () => void;
+  uploadAvatarMutation: UseMutationResult<unknown, Error, File, unknown>;
+  setClientSideUploadError: (error: string | null) => void;
+}
+
+export default function ImageCropDialog({
+  open,
+  imageToCropSrc,
+  originalFile,
+  onCropConfirm,
+  onCropCancel,
+  uploadAvatarMutation,
+  setClientSideUploadError,
+}: ImageCropModalProps) {
+  const [crop, setCrop] = useState<Crop | undefined>();
+  const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
+  const imgRef = useRef<HTMLImageElement>(null);
+
+  const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
+    const { width, height, naturalWidth } = e.currentTarget;
+
+    const crop = makeAspectCrop(
+      {
+        unit: "%",
+        width: Math.min(
+          90,
+          (OUTPUT_AVATAR_SIZE / naturalWidth) * 100 * (naturalWidth / width),
+        ),
+      },
+      1,
+      width,
+      height,
+    );
+    const centeredCrop = centerCrop(crop, width, height);
+    setCrop(centeredCrop);
+    setCompletedCrop({
+      ...centeredCrop,
+      unit: "px",
+      x: (centeredCrop.x / 100) * width,
+      y: (centeredCrop.y / 100) * height,
+      width: (centeredCrop.width / 100) * width,
+      height: (centeredCrop.height / 100) * height,
+    });
+  };
path: apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Improve crop initialization calculation for better UX.**

The crop size calculation could be more robust. The current formula may result in unexpected crop sizes for very large or very small images.


Consider simplifying and making the crop initialization more predictable:

```diff
  const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
    const { width, height, naturalWidth } = e.currentTarget;

    const crop = makeAspectCrop(
      {
        unit: "%",
-       width: Math.min(
-         90,
-         (OUTPUT_AVATAR_SIZE / naturalWidth) * 100 * (naturalWidth / width),
-       ),
+       width: 80, // Start with 80% of the image width
      },
      1,
      width,
      height,
    );
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
  const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
    const { width, height, naturalWidth } = e.currentTarget;

    const crop = makeAspectCrop(
      {
        unit: "%",
        width: 80, // Start with 80% of the image width
      },
      1,
      width,
      height,
    );
    const centeredCrop = centerCrop(crop, width, height);
    setCrop(centeredCrop);
    setCompletedCrop({
      ...centeredCrop,
      unit: "px",
      x: (centeredCrop.x / 100) * width,
      y: (centeredCrop.y / 100) * height,
      width: (centeredCrop.width / 100) * width,
      height: (centeredCrop.height / 100) * height,
    });
  };
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx around lines
113 to 138, the crop size calculation in onImageLoad is overly complex and can
produce unpredictable crop sizes for very large or small images. Simplify the
calculation by setting a fixed initial crop size percentage or a more
straightforward formula that scales predictably with the image dimensions. This
will make the crop initialization more consistent and improve user experience.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T03:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+import { useMutation } from "@tanstack/react-query";
+import { auth } from "@/auth/client";
+import type { User } from "better-auth/types";
+import type { QueryClient } from "@tanstack/react-query";
+
+interface UseAvatarMutationsProps {
+  user: User | null;
+  queryClient: QueryClient;
+  setClientSideUploadError: (error: string | null) => void;
+  setOptimisticAvatarUrl: (url: string | null) => void;
+  fileInputRef: React.RefObject<HTMLInputElement | null>;
+  setCropModalOpen: (open: boolean) => void;
+}
+
+export function useAvatarMutations({
+  user,
+  queryClient,
+  setClientSideUploadError,
+  setOptimisticAvatarUrl,
+  fileInputRef,
+  setCropModalOpen,
+}: UseAvatarMutationsProps) {
+  const deleteS3ObjectMutation = useMutation({
+    mutationFn: async (key: string) => {
+      const url = new URL(
+        `${process.env.NEXT_PUBLIC_API_URL}/user/delete-avatar`,
+      );
+      url.searchParams.append("key", key);
+      const response = await fetch(url.toString(), {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+        credentials: "include",
+      });
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => ({}));
+        throw new Error(
+          errorData.message || "Failed to delete avatar from storage.",
+        );
+      }
+      return response.json();
+    },
+  });
+
+  const uploadAvatarMutation = useMutation({
+    mutationFn: async (file: File) => {
+      setClientSideUploadError(null);
+      const fileName = encodeURIComponent(file.name);
+      const type = file.type;
+
+      const presignedUrlResponse = await fetch(
+        `${process.env.NEXT_PUBLIC_API_URL}/user/upload-avatar`,
+        {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          credentials: "include",
+          body: JSON.stringify({ fileName, type }),
+        },
+      );
+      if (!presignedUrlResponse.ok) {
+        const errorData = await presignedUrlResponse.json().catch(() => ({}));
+        throw new Error(errorData.message || "Failed to get upload URL.");
+      }
+      const { url: S3UploadUrl, key: newImageKey } = (
+        await presignedUrlResponse.json()
+      ).data;
+
+      const uploadResponse = await fetch(S3UploadUrl, {
+        method: "PUT",
+        headers: { "Content-Type": type },
+        body: file,
+      });
+      if (!uploadResponse.ok) {
+        throw new Error("File upload to storage failed.");
+      }
+
+      const oldImageKey = user?.image;
+      if (oldImageKey && oldImageKey !== newImageKey) {
+        deleteS3ObjectMutation.mutate(oldImageKey, {
+          onError: (error) =>
+            console.error(
+              "Failed to delete old S3 object during update:",
+              error,
+            ),
+        });
+      }
+
+      const updateUserResponse = await auth.updateUser({ image: newImageKey });
+      if (updateUserResponse.error) {
+        try {
+          await deleteS3ObjectMutation.mutateAsync(newImageKey);
+        } catch (cleanupError) {
+          console.error(
+            "Failed to clean up newly uploaded avatar after DB update failure:",
+            cleanupError,
+          );
+        }
+        throw new Error(
+          updateUserResponse.error.message || "Failed to update user profile.",
+        );
+      }
+      return updateUserResponse.data;
+    },
+    onMutate: async (file: File) => {
+      const localUrl = URL.createObjectURL(file);
+      setOptimisticAvatarUrl(localUrl);
+
+      await queryClient.cancelQueries({
+        queryKey: ["avatar-url", user?.id, user?.image],
+      });
+      const previousImageUrl = queryClient.getQueryData([
+        "avatar-url",
+        user?.id,
+        user?.image,
+      ]);
+      queryClient.setQueryData(["avatar-url", user?.id, user?.image], localUrl);
+      return { localUrl, previousImageUrl };
+    },
+    onSuccess: (_data, _variables, _context) => {
+      queryClient.invalidateQueries({ queryKey: ["currentSession"] });
+      queryClient.invalidateQueries({ queryKey: ["avatar-url"], exact: false });
+    },
+    onError: (error, _variables, context) => {
+      setClientSideUploadError(error.message || "Avatar upload failed.");
+      if (context?.localUrl) {
+        URL.revokeObjectURL(context.localUrl);
+      }
+      if (context?.previousImageUrl) {
+        queryClient.setQueryData(
+          ["avatar-url", user?.id, user?.image],
+          context.previousImageUrl,
+        );
+      }
+      setOptimisticAvatarUrl(null);
+    },
+    onSettled: (_data, _error, _variables, context) => {
+      if (context?.localUrl) {
+        URL.revokeObjectURL(context.localUrl);
+      }
+      // biome-ignore lint: shut up biome
+      if (fileInputRef && fileInputRef.current) {
+        fileInputRef.current.value = "";
+      }
+      setCropModalOpen(false);
+      setOptimisticAvatarUrl(null);
path: apps/frontend/src/hooks/use-avatar.tsx
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Minor code style: Remove unnecessary biome ignore comment.**

The biome ignore comment appears unnecessary as the null check is valid and should not trigger linting issues.


```diff
-     // biome-ignore lint: shut up biome
      if (fileInputRef && fileInputRef.current) {
        fileInputRef.current.value = "";
      }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
      if (fileInputRef && fileInputRef.current) {
        fileInputRef.current.value = "";
      }
      setCropModalOpen(false);
      setOptimisticAvatarUrl(null);
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apps/frontend/src/hooks/use-avatar.tsx around lines 139 to 144, remove the
biome-ignore lint comment before the null check on fileInputRef.current, as the
null check is valid and does not require disabling linting. This will clean up
the code style by eliminating an unnecessary lint suppression.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T03:23:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,206 @@
+import { useMutation } from "@tanstack/react-query";
+import { auth } from "@/auth/client";
+import type { User } from "better-auth/types";
+import type { QueryClient } from "@tanstack/react-query";
+
+interface UseAvatarMutationsProps {
+  user: User | null;
+  queryClient: QueryClient;
+  setClientSideUploadError: (error: string | null) => void;
+  setOptimisticAvatarUrl: (url: string | null) => void;
+  fileInputRef: React.RefObject<HTMLInputElement | null>;
+  setCropModalOpen: (open: boolean) => void;
+}
+
+export function useAvatarMutations({
+  user,
+  queryClient,
+  setClientSideUploadError,
+  setOptimisticAvatarUrl,
+  fileInputRef,
+  setCropModalOpen,
+}: UseAvatarMutationsProps) {
+  const deleteS3ObjectMutation = useMutation({
+    mutationFn: async (key: string) => {
+      const url = new URL(
+        `${process.env.NEXT_PUBLIC_API_URL}/user/delete-avatar`,
+      );
+      url.searchParams.append("key", key);
+      const response = await fetch(url.toString(), {
+        method: "DELETE",
+        headers: { "Content-Type": "application/json" },
+        credentials: "include",
+      });
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => ({}));
+        throw new Error(
+          errorData.message || "Failed to delete avatar from storage.",
+        );
+      }
+      return response.json();
+    },
+  });
+
+  const uploadAvatarMutation = useMutation({
+    mutationFn: async (file: File) => {
+      setClientSideUploadError(null);
+      const fileName = encodeURIComponent(file.name);
+      const type = file.type;
+
+      const presignedUrlResponse = await fetch(
+        `${process.env.NEXT_PUBLIC_API_URL}/user/upload-avatar`,
+        {
+          method: "POST",
+          headers: { "Content-Type": "application/json" },
+          credentials: "include",
+          body: JSON.stringify({ fileName, type }),
+        },
+      );
+      if (!presignedUrlResponse.ok) {
+        const errorData = await presignedUrlResponse.json().catch(() => ({}));
+        throw new Error(errorData.message || "Failed to get upload URL.");
+      }
+      const { url: S3UploadUrl, key: newImageKey } = (
+        await presignedUrlResponse.json()
+      ).data;
+
+      const uploadResponse = await fetch(S3UploadUrl, {
+        method: "PUT",
+        headers: { "Content-Type": type },
+        body: file,
+      });
+      if (!uploadResponse.ok) {
+        throw new Error("File upload to storage failed.");
+      }
+
+      const oldImageKey = user?.image;
+      if (oldImageKey && oldImageKey !== newImageKey) {
+        deleteS3ObjectMutation.mutate(oldImageKey, {
+          onError: (error) =>
+            console.error(
+              "Failed to delete old S3 object during update:",
+              error,
+            ),
+        });
+      }
path: apps/frontend/src/hooks/use-avatar.tsx
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Potential race condition in old image deletion.**

The old image deletion is fire-and-forget, which could lead to orphaned S3 objects if the deletion fails. Consider making this operation more robust.


Consider improving the old image cleanup logic:

```diff
      const oldImageKey = user?.image;
      if (oldImageKey && oldImageKey !== newImageKey) {
-       deleteS3ObjectMutation.mutate(oldImageKey, {
-         onError: (error) =>
-           console.error(
-             "Failed to delete old S3 object during update:",
-             error,
-           ),
-       });
+       try {
+         await deleteS3ObjectMutation.mutateAsync(oldImageKey);
+       } catch (error) {
+         console.error(
+           "Failed to delete old S3 object during update:",
+           error,
+         );
+         // Could implement retry logic or queue for later cleanup
+       }
      }
```


> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In apps/frontend/src/hooks/use-avatar.tsx around lines 76 to 85, the deletion of
the old S3 image is currently fire-and-forget, risking orphaned objects if
deletion fails. Modify the code to handle the deletion operation more robustly
by awaiting the deletion mutation or implementing retry logic on failure. Ensure
that errors are properly caught and handled, possibly by notifying the user or
logging for later cleanup, to prevent orphaned S3 objects.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-28T03:23:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-28T03:19:20Z
body: [vc]: #7nDiozouDlemiGsaVOcbbJFO6fKBb7AjJjKepjZ70aY=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJmbGFpcmEtZnJvbnRlbmQiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vYnJ1bm8tYXNlZmZzLXByb2plY3RzL2ZsYWlyYS1mcm9udGVuZC9Gd2MydHhmTk4zRUw0YmdOOVFkUE5ZeEE0QTNHIiwicHJldmlld1VybCI6ImZsYWlyYS1mcm9udGVuZC1naXQtZGV2ZWxvcC1icnVuby1hc2VmZnMtcHJvamVjdHMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJmbGFpcmEtZnJvbnRlbmQtZ2l0LWRldmVsb3AtYnJ1bm8tYXNlZmZzLXByb2plY3RzLnZlcmNlbC5hcHAifSwicm9vdERpcmVjdG9yeSI6ImFwcHMvZnJvbnRlbmQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **flaira-frontend** | ✅ Ready ([Inspect](https://vercel.com/bruno-aseffs-projects/flaira-frontend/Fwc2txfNN3EL4bgN9QdPNYxA4A3G)) | [Visit Preview](https://flaira-frontend-git-develop-bruno-aseffs-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/flaira-frontend-git-develop-bruno-aseffs-projects.vercel.app?via=pr-comment-feedback-link) | May 29, 2025 11:00am |


author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-05-28T03:19:22Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

The changes remove the original `AvatarUpload` component and introduce a new, modular avatar upload system, including components for avatar display, cropping, and upload logic, as well as a custom React hook for managing avatar mutations. Related UI components are updated to use the new structure, and styling adjustments are made throughout.

## Changes

| File(s)                                                                                      | Change Summary                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
|----------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| apps/frontend/src/components/settings/AvatarUpload.tsx                                       | Old `AvatarUpload` component deleted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| apps/frontend/src/components/settings/ProfileTab.tsx                                         | Updates import path for `AvatarUpload` and increases font size for name/email inputs.                                                                                                                                                                                                                                                                                                                                                                                                                            |
| apps/frontend/src/components/settings/SettingsDialog.tsx                                     | Adjusts tab list and content styling for improved spacing and font/icon sizing.                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| apps/frontend/src/components/settings/DeleteAccountDialog.tsx                                | Refines dialog content, title, and description styling for better responsiveness and appearance.                                                                                                                                                                                                                                                                                                                                                                                                                |
| apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx                               | Adds new `AvatarDisplay` component for rendering user avatar with interactive menu and state-based UI.                                                                                                                                                                                                                                                                                                                                                                                                           |
| apps/frontend/src/components/settings/avatar/AvatarUpload.tsx                                | Adds new `AvatarUpload` component handling avatar upload, cropping, removal, error management, and UI state.                                                                                                                                                                                                                                                                                                                                                                                                     |
| apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx                             | Adds new `ImageCropDialog` component for cropping avatar images before upload, using `react-image-crop`.                                                                                                                                                                                                                                                                                                                                                                                                        |
| apps/frontend/src/components/sidebar/UserButton.tsx                                          | Adds CSS classes to `AvatarImage` for proper scaling and coverage.                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| apps/frontend/src/hooks/use-avatar.tsx                                                       | Adds new `useAvatarMutations` hook for managing avatar upload and removal mutations, including optimistic UI and error handling.                                                                                                                                                                                                                                                                                                                                          |
| package.json                                                                                 | Adds `react-image-crop` dependency.                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant AvatarUpload
    participant ImageCropDialog
    participant useAvatarMutations
    participant BackendAPI
    participant S3

    User->>AvatarUpload: Click "Update avatar"
    AvatarUpload->>User: Open file picker
    User->>AvatarUpload: Select image file
    AvatarUpload->>ImageCropDialog: Open crop modal with image
    User->>ImageCropDialog: Crop and confirm
    ImageCropDialog->>useAvatarMutations: Trigger upload mutation with cropped file
    useAvatarMutations->>BackendAPI: Request presigned S3 upload URL
    BackendAPI-->>useAvatarMutations: Return presigned URL
    useAvatarMutations->>S3: Upload file to S3 via presigned URL
    useAvatarMutations->>BackendAPI: Update user profile with new avatar key
    useAvatarMutations->>S3: (If old avatar exists) Delete old avatar
    useAvatarMutations->>AvatarUpload: Update UI (optimistic and on success)
```

```mermaid
sequenceDiagram
    participant User
    participant AvatarUpload
    participant useAvatarMutations
    participant BackendAPI
    participant S3

    User->>AvatarUpload: Click "Remove avatar"
    AvatarUpload->>useAvatarMutations: Trigger remove mutation
    useAvatarMutations->>BackendAPI: Update user profile (remove avatar key)
    useAvatarMutations->>S3: Delete avatar image from S3
    useAvatarMutations->>AvatarUpload: Update UI (optimistic and on success)
```

## Possibly related PRs

- [BrunoAseff/flaira#106](https://github.com/BrunoAseff/flaira/pull/106): Introduced the original `AvatarUpload` component, which is now being refactored and replaced in this PR.
- [BrunoAseff/flaira#96](https://github.com/BrunoAseff/flaira/pull/96): Added backend routes and AWS S3 integration for avatar upload and retrieval, which the new frontend avatar upload logic depends on.
- [BrunoAseff/flaira#111](https://github.com/BrunoAseff/flaira/pull/111): Renamed query cache keys in the old `AvatarUpload` component, related to the same feature but not overlapping in implementation.

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: ASSERTIVE**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 384ff83ad4bb7201743cf7d04aa9ec7a6ffeef42 and 45b2cc3ae0c7b7ad7290015f02526788774adbcb.

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `apps/frontend/src/components/settings/avatar/AvatarUpload.tsx` (1 hunks)
* `apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>apps/frontend/src/components/settings/avatar/AvatarUpload.tsx (4)</summary><blockquote>

<details>
<summary>apps/frontend/src/hooks/use-avatar.tsx (1)</summary>

* `useAvatarMutations` (15-206)

</details>
<details>
<summary>apps/frontend/src/components/settings/avatar/AvatarDisplay.tsx (1)</summary>

* `AvatarDisplay` (32-124)

</details>
<details>
<summary>apps/frontend/src/components/ui/input.tsx (1)</summary>

* `Input` (49-49)

</details>
<details>
<summary>apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx (1)</summary>

* `ImageCropDialog` (100-218)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (10)</summary><blockquote>

<details>
<summary>apps/frontend/src/components/settings/avatar/AvatarUpload.tsx (5)</summary>

`1-11`: **LGTM: Clean and well-organized imports.**

The imports are properly structured and all appear to be necessary for the component functionality.

---

`12-64`: **LGTM: Well-structured component setup with proper state management.**

The component initialization is clean with appropriate state variables and React Query configuration. The avatar URL fetching has good caching strategy and conditional enabling.

---

`91-118`: **LGTM: Well-implemented event handlers with proper validation and cleanup.**

The event handlers correctly manage the avatar workflow, including proper validation for removal and memory leak prevention through object URL cleanup.

---

`120-130`: **LGTM: Proper cleanup implementation prevents memory leaks.**

The effect correctly handles object URL cleanup with appropriate dependencies to prevent memory leaks from blob URLs.

---

`139-187`: **LGTM: Clean rendering with proper component composition.**

The rendering logic is well-structured with appropriate prop passing to child components. The error display condition correctly uses logical operators to show errors when any mutation fails or client-side errors occur.

</details>
<details>
<summary>apps/frontend/src/components/settings/avatar/ImageCropDialog.tsx (5)</summary>

`1-23`: **LGTM: Appropriate imports and sensible avatar size constant.**

The imports are well-organized and the 256px output size is appropriate for avatar images.

---

`24-88`: **LGTM: Robust image processing with proper scaling and circular cropping.**

The getCroppedBlob function correctly handles canvas operations, scaling calculations, circular clipping, and blob conversion with appropriate error handling.

---

`90-136`: **LGTM: Clean interface and improved crop initialization.**

The props interface is well-defined and the crop initialization has been simplified to use a fixed 80% width, which is much more predictable than the complex formula mentioned in previous reviews.

---

`137-156`: **LGTM: Proper validation and error handling for crop confirmation.**

The function correctly validates required values and handles errors appropriately. The minimum crop size concern from previous reviews is addressed by the minWidth/minHeight constraints on the ReactCrop component.

---

`158-218`: **LGTM: Well-implemented dialog with proper crop controls and button states.**

The dialog correctly configures ReactCrop for circular cropping with appropriate constraints and manages button states based on crop completion and upload status. The minimum crop size validation is handled through the minWidth/minHeight props.

</details>

</blockquote></details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=BrunoAseff/flaira&utm_content=112):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR