Handle cropping + avatar deletion

This commit is contained in:
Arthur Belleville 2025-10-19 10:06:14 +02:00
parent 599633b29d
commit 575240b7e3
No known key found for this signature in database
7 changed files with 379 additions and 26 deletions

View file

@ -9,7 +9,12 @@ import {
streamChatMiddleware,
} from "./middleware.js";
import { transporter } from "./transporter.js";
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import {
DeleteObjectsCommand,
ListObjectsV2Command,
PutObjectCommand,
type S3Client,
} from "@aws-sdk/client-s3";
export const userRouter = new Hono<{
Variables: {
@ -205,8 +210,11 @@ userRouter.post("/profile/avatar", async (c) => {
return c.json({ error: "Content is required" }, 400);
}
const randomString = Math.random().toString(36).substring(2, 15);
const base64Content = Buffer.from(content, "base64");
const key = `${user.id}/public_avatar.${contentType.split("/")[1]}`;
const key = `${user.id}/public_avatar_${randomString}.${
contentType.split("/")[1]
}`;
try {
await s3Client.send(
@ -241,3 +249,46 @@ userRouter.post("/profile/avatar", async (c) => {
profile: data,
});
});
userRouter.delete("/profile/avatar", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3Client = c.get("s3_client");
try {
const listedObjects = await s3Client.send(
new ListObjectsV2Command({
Bucket: "web-assets",
Prefix: `${user.id}/`,
})
);
if (listedObjects.Contents.length === 0)
return c.json({ error: "No objects found" }, 404);
await s3Client.send(
new DeleteObjectsCommand({
Bucket: "web-assets",
Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) },
})
);
} catch (error) {
console.error("Failed to delete avatar:", error);
return c.json({ error: "Failed to delete avatar" }, 500);
}
const { error } = await supabase
.from("profiles")
.update({ avatar_url: null })
.eq("id", user.id)
.select()
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
message: "Avatar deleted successfully",
});
});

View file

@ -78,6 +78,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@ -97,6 +98,7 @@
"jspdf": "^3.0.3",
"jwt-decode": "^4.0.0",
"react-day-picker": "^9.11.1",
"react-easy-crop": "^5.5.3",
"react-hook-form": "^7.65.0",
"react-router-dom": "^7.9.4",
"react-stately": "^3.36.1",

View file

@ -48,6 +48,9 @@ importers:
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slider':
specifier: ^1.3.6
version: 1.3.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.0.10)(react@19.0.0)
@ -105,6 +108,9 @@ importers:
react-day-picker:
specifier: ^9.11.1
version: 9.11.1(react@19.0.0)
react-easy-crop:
specifier: ^5.5.3
version: 5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-hook-form:
specifier: ^7.65.0
version: 7.65.0(react@19.0.0)
@ -1546,6 +1552,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-slider@1.3.6':
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@ -4682,6 +4701,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-wheel@1.0.1:
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@ -4974,6 +4996,12 @@ packages:
peerDependencies:
react: '>= 16.8 || 18.0.0'
react-easy-crop@5.5.3:
resolution: {integrity: sha512-iKwFTnAsq+IVuyF6N0Q3zjRx9DG1NMySkwWxVfM/xAOeHYH1vhvM+V2kFiq5HOIQGWouITjfltCx54mbDpMpmA==}
peerDependencies:
react: '>=16.4.0'
react-dom: '>=16.4.0'
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
@ -7175,6 +7203,25 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-slot@1.2.3(@types/react@19.0.10)(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0)
@ -11365,6 +11412,8 @@ snapshots:
normalize-path@3.0.0: {}
normalize-wheel@1.0.1: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@ -11674,6 +11723,13 @@ snapshots:
prop-types: 15.8.1
react: 19.0.0
react-easy-crop@5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
normalize-wheel: 1.0.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tslib: 2.8.1
react-fast-compare@3.2.2: {}
react-hook-form@7.65.0(react@19.0.0):

View file

@ -0,0 +1,158 @@
import { useState, useCallback } from "react";
import Cropper from "react-easy-crop";
import type { Area } from "react-easy-crop";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/ui/dialog";
import { Button } from "@ui/components/ui/button";
import { Slider } from "@ui/components/ui/slider";
import { Label } from "@ui/components/ui/label";
interface ImageCropDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageSrc: string;
onCropComplete: (croppedImage: Blob) => void;
}
/**
* Creates a cropped image from the original image and crop area
*/
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<Blob> {
const image = await createImage(imageSrc);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
}
// Set canvas size to the desired crop size
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
// Draw the cropped portion of the image
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
);
// Convert canvas to blob
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("Canvas is empty"));
}
}, "image/jpeg");
});
}
/**
* Loads an image from a URL
*/
function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", (error) => reject(error));
image.src = url;
});
}
export function ImageCropDialog({
open,
onOpenChange,
imageSrc,
onCropComplete,
}: ImageCropDialogProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [isCropping, setIsCropping] = useState(false);
const onCropAreaChange = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);
const handleCropConfirm = useCallback(async () => {
if (!croppedAreaPixels) return;
try {
setIsCropping(true);
const croppedImage = await getCroppedImg(imageSrc, croppedAreaPixels);
onCropComplete(croppedImage);
onOpenChange(false);
} catch (error) {
console.error("Error cropping image:", error);
} finally {
setIsCropping(false);
}
}, [croppedAreaPixels, imageSrc, onCropComplete, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Recadrer l'image</DialogTitle>
<DialogDescription>
Ajustez la position et le zoom pour recadrer votre photo de profil
</DialogDescription>
</DialogHeader>
<div className="relative w-full h-[400px] bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onCropComplete={onCropAreaChange}
onZoomChange={setZoom}
/>
</div>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="zoom" className="text-sm font-medium">
Zoom
</Label>
<Slider
id="zoom"
min={1}
max={3}
step={0.1}
value={[zoom]}
onValueChange={(value) => setZoom(value[0])}
className="w-full"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCropping}>
Annuler
</Button>
<Button onClick={handleCropConfirm} disabled={isCropping}>
{isCropping ? "Traitement..." : "Confirmer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { cn } from "@ui/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View file

@ -127,7 +127,7 @@ export const useUploadAvatar = () => {
position: "top-center",
});
// Refresh user data
queryClient.invalidateQueries({ queryKey: ["user"] as QueryKey });
queryClient.invalidateQueries({ queryKey: ["user"] });
},
onError: (error: Error) => {
toast.add({
@ -141,3 +141,26 @@ export const useUploadAvatar = () => {
});
return { mutate, isPending };
};
export const useRemoveAvatar = () => {
const { session } = useSession();
const { mutateAsync, isPending } = useMutation({
mutationFn: async () => {
await api.delete("/api/v1/users/profile/avatar", {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
},
onSuccess: () => {
toast.add({
title: "Avatar supprimé",
description: "Votre photo de profil a été supprimée avec succès",
type: "success",
position: "top-center",
});
queryClient.invalidateQueries({ queryKey: ["user"] });
},
});
return { mutateAsync, isPending };
};

View file

@ -6,11 +6,12 @@ import { Textarea } from "@ui/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/ui/avatar";
import { useUser } from "@ui/providers/UserStoreProvider";
import { useState, useRef } from "react";
import { TypographyH3, TypographyMuted } from "src/components/ui/typography";
import { TypographyH3, TypographyMuted, TypographySmall } from "@ui/components/ui/typography";
import { useIntroduction } from "src/hooks/intros";
import { useUpdateProfile, useUploadAvatar } from "src/hooks/profile";
import { Trash2Icon, UploadIcon } from "lucide-react";
import { toast } from "src/lib/toast";
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "@ui/hooks/profile";
import { CameraIcon, Trash2Icon, UploadIcon } from "lucide-react";
import { toast } from "@ui/lib/toast";
import { ImageCropDialog } from "@ui/components/ImageCropDialog";
export default function SettingsPage() {
const user = useUser();
@ -22,11 +23,14 @@ export default function SettingsPage() {
} = useIntroduction();
const { mutate: updateProfile, isPending: updateProfilePending } = useUpdateProfile();
const { mutate: uploadAvatar } = useUploadAvatar();
const { mutateAsync: removeAvatar } = useRemoveAvatar();
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatar_url || null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [imageToCrop, setImageToCrop] = useState<string | null>(null);
const [isCropDialogOpen, setIsCropDialogOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -54,18 +58,18 @@ export default function SettingsPage() {
return;
}
setSelectedFile(file);
// Create preview
// Load image for cropping
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
setImageToCrop(reader.result as string);
setIsCropDialogOpen(true);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
const handleRemoveAvatar = async () => {
await removeAvatar();
setAvatarPreview(null);
setSelectedFile(null);
if (fileInputRef.current) {
@ -79,6 +83,27 @@ export default function SettingsPage() {
}
};
const handleCropComplete = (croppedImageBlob: Blob) => {
// Convert blob to File
const croppedFile = new File([croppedImageBlob], "avatar.jpg", {
type: "image/jpeg",
});
setSelectedFile(croppedFile);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(croppedImageBlob);
// Reset file input to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
return (
<div className="min-h-screen bg-background">
<div className="container max-w-3xl mx-auto py-6 px-4">
@ -104,12 +129,17 @@ export default function SettingsPage() {
</AvatarFallback>
</Avatar>
{/* Camera overlay on hover
{!avatarPreview && (
{imageToCrop && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
<CameraIcon className="w-8 h-8 text-white" />
<Button
variant="outline"
size="icon"
onClick={() => setIsCropDialogOpen(true)}
>
<CameraIcon className="w-4 h-4" />
</Button>
</div>
)} */}
)}
</div>
{/* Upload Controls */}
@ -145,6 +175,15 @@ export default function SettingsPage() {
{avatarPreview && (
<>
{selectedFile && (
<Button
size="sm"
onClick={handleUploadAvatar}
className="gap-2 bg-gradient-to-r from-purple-500/80 to-blue-500/80 hover:from-purple-500 hover:to-blue-500 dark:from-purple-500/80 dark:to-blue-500/80 dark:hover:from-purple-500 dark:hover:to-blue-500"
>
<TypographySmall>Enregistrer l'avatar</TypographySmall>
</Button>
)}
<Button
variant="outline"
size="sm"
@ -154,16 +193,6 @@ export default function SettingsPage() {
<Trash2Icon className="w-4 h-4" />
Supprimer
</Button>
{selectedFile && (
<Button
size="sm"
onClick={handleUploadAvatar}
className="gap-2 bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700"
>
Enregistrer l'avatar
</Button>
)}
</>
)}
</div>
@ -267,6 +296,16 @@ export default function SettingsPage() {
</Card>
</div>
</div>
{/* Image Crop Dialog */}
{imageToCrop && (
<ImageCropDialog
open={isCropDialogOpen}
onOpenChange={setIsCropDialogOpen}
imageSrc={imageToCrop}
onCropComplete={handleCropComplete}
/>
)}
</div>
);
}