diff --git a/api/src/user.ts b/api/src/user.ts index d879537..de84243 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -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", + }); +}); diff --git a/ui/package.json b/ui/package.json index ec31299..8dd85b5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 7284010..e5d6c24 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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): diff --git a/ui/src/components/ImageCropDialog.tsx b/ui/src/components/ImageCropDialog.tsx new file mode 100644 index 0000000..2782875 --- /dev/null +++ b/ui/src/components/ImageCropDialog.tsx @@ -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 { + 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 { + 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(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 ( + + + + Recadrer l'image + + Ajustez la position et le zoom pour recadrer votre photo de profil + + + +
+ +
+ +
+
+ + setZoom(value[0])} + className="w-full" + /> +
+
+ + + + + +
+
+ ); +} diff --git a/ui/src/components/ui/slider.tsx b/ui/src/components/ui/slider.tsx new file mode 100644 index 0000000..8183f66 --- /dev/null +++ b/ui/src/components/ui/slider.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/ui/src/hooks/profile.ts b/ui/src/hooks/profile.ts index dc9ae5d..814cefe 100644 --- a/ui/src/hooks/profile.ts +++ b/ui/src/hooks/profile.ts @@ -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 }; +}; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 9d9d01f..52ace62 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -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(user?.avatar_url || null); const [selectedFile, setSelectedFile] = useState(null); + const [imageToCrop, setImageToCrop] = useState(null); + const [isCropDialogOpen, setIsCropDialogOpen] = useState(false); const fileInputRef = useRef(null); const handleAvatarChange = (e: React.ChangeEvent) => { @@ -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 (
@@ -104,12 +129,17 @@ export default function SettingsPage() { - {/* Camera overlay on hover - {!avatarPreview && ( + {imageToCrop && (
- +
- )} */} + )}
{/* Upload Controls */} @@ -145,6 +175,15 @@ export default function SettingsPage() { {avatarPreview && ( <> + {selectedFile && ( + + )} - - {selectedFile && ( - - )} )}
@@ -267,6 +296,16 @@ export default function SettingsPage() { + + {/* Image Crop Dialog */} + {imageToCrop && ( + + )} ); }