Handle cropping + avatar deletion
This commit is contained in:
parent
599633b29d
commit
575240b7e3
7 changed files with 379 additions and 26 deletions
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
158
ui/src/components/ImageCropDialog.tsx
Normal file
158
ui/src/components/ImageCropDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
ui/src/components/ui/slider.tsx
Normal file
24
ui/src/components/ui/slider.tsx
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue