Skip to content

Commit 6e697ee

Browse files
authored
Merge pull request #143 from MrLionByte/frontend
feat: add image crop in profile
2 parents fb88b82 + fbbfbd3 commit 6e697ee

File tree

5 files changed

+173
-10
lines changed

5 files changed

+173
-10
lines changed

frontend/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"next-themes": "^0.4.4",
3636
"react": "^19.0.0",
3737
"react-dom": "^19.0.0",
38+
"react-easy-crop": "^5.4.1",
3839
"react-hook-form": "^7.54.2",
3940
"react-icons": "^5.5.0",
4041
"react-markdown": "^10.0.1",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { FC } from 'react'
2+
import Cropper, { Area } from 'react-easy-crop';
3+
4+
interface ImageModalProps {
5+
image: string | null;
6+
crop: { x: number; y: number };
7+
setCrop: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
8+
zoom: number;
9+
setZoom: React.Dispatch<React.SetStateAction<number>>;
10+
onCropComplete: (croppedArea: Area,
11+
croppedAreaPixels: Area) => void;
12+
handleSave: () => void;
13+
closeModal: () => void;
14+
}
15+
16+
const ImageModal:FC<ImageModalProps>= ({
17+
image,
18+
crop,
19+
setCrop,
20+
zoom,
21+
setZoom,
22+
onCropComplete,
23+
handleSave,
24+
closeModal
25+
}) => {
26+
return (
27+
<div className="fixed inset-0 bg-black flex flex-col gap-5
28+
items-center justify-center z-50">
29+
<div className="bg-white p-5 rounded-lg shadow-lg w-96 h-96 relative">
30+
<Cropper
31+
image={image ?? undefined}
32+
crop={crop}
33+
zoom={zoom}
34+
aspect={1}
35+
onCropChange={setCrop}
36+
onZoomChange={setZoom}
37+
onCropComplete={onCropComplete}
38+
/>
39+
40+
</div>
41+
<div className="flex justify-between gap-7">
42+
<button onClick={() => handleSave()} className="bg-blue-800 text-white hover:bg-neutral-300 dark:bg-blue-800
43+
hover:text-gray-700 dark:text-gray-200 rounded-full p-1 dark:hover:bg-gray-800
44+
px-4 py-2 cursor-pointer">Save</button>
45+
<button onClick={() => closeModal()} className="bg-red-800 text-white hover:bg-neutral-300 dark:bg-red-800
46+
hover:text-gray-700 dark:text-gray-200 rounded-full p-1 dark:hover:bg-gray-800
47+
px-4 py-2 cursor-pointer">Cancel</button>
48+
</div>
49+
</div>
50+
)
51+
}
52+
53+
export default ImageModal;

frontend/src/components/user/editProfile/PictureInput.tsx

+49-10
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { Input } from "@/components/ui/input";
22
import { Button } from "@/components/ui/button";
33
import { X } from "lucide-react";
4-
import { FC, useState, useRef } from "react";
4+
import { FC, useState, useRef, useCallback } from "react";
5+
import ImageModal from "./ImageModal";
6+
import { getCroppedImg } from "./cropImage";
7+
import { Area } from "react-easy-crop";
58

69
const PictureInput: FC = () => {
7-
const [image, setImage] = useState<string | null>(
8-
"https://res.cloudinary.com/dwyxogyrk/image/upload/v1737433466/h0xf7zi0blmclfqrjeo7.png"
9-
);
10+
const [image, setImage] = useState<string | null>(null);
11+
const [croppedImage, setCroppedImage] = useState<string | null>(null);
12+
const [imageModal, setImageModal] = useState<boolean>(false);
1013
const fileInputRef = useRef<HTMLInputElement>(null);
14+
const [crop, setCrop] = useState({ x: 0, y: 0 });
15+
const [zoom, setZoom] = useState(1);
16+
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
1117

1218
// Handle file selection
1319
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -16,8 +22,8 @@ const PictureInput: FC = () => {
1622
const reader = new FileReader();
1723
reader.onload = () => setImage(reader.result as string);
1824
reader.readAsDataURL(file);
25+
setImageModal(true);
1926
}
20-
2127
// Reset input value to allow selecting the same file again
2228
if (fileInputRef.current) {
2329
fileInputRef.current.value = "";
@@ -30,7 +36,27 @@ const PictureInput: FC = () => {
3036
};
3137

3238
// Remove selected image
33-
const removeImage = () => setImage(null);
39+
const removeImage = () => {
40+
setImage(null);
41+
setCroppedImage(null);
42+
}
43+
44+
const onCropComplete = useCallback((_val: Area,
45+
croppedAreaPixels: Area) => {
46+
setCroppedAreaPixels(croppedAreaPixels);
47+
}, []);
48+
49+
const handleSaveImage = async() => {
50+
if (image && croppedAreaPixels){
51+
try{
52+
const croppedImage = await getCroppedImg(image, croppedAreaPixels);
53+
setCroppedImage(croppedImage);
54+
setImageModal(false);
55+
} catch (e) {
56+
console.error(e);
57+
}
58+
}
59+
}
3460

3561
return (
3662
<div className="flex w-full max-w-sm items-center gap-3 bg-gray-200 dark:bg-gray-900 p-4 rounded-2xl relative shadow-md">
@@ -43,8 +69,9 @@ const PictureInput: FC = () => {
4369
</button>
4470
)}
4571

46-
{image ? (
47-
<img className="w-24 h-24 rounded-2xl object-cover border border-gray-300 dark:border-gray-700" src={image} alt="Profile" />
72+
{croppedImage ? (
73+
<img className="w-24 h-24 rounded-2xl object-cover border border-gray-300
74+
dark:border-gray-700" src={croppedImage} alt="Profile" />
4875
) : (
4976
<div className="w-24 h-24 rounded-2xl bg-gray-400 dark:bg-gray-800 flex items-center justify-center text-white">
5077
No Image
@@ -65,7 +92,7 @@ const PictureInput: FC = () => {
6592
{/* Button to Trigger File Input */}
6693
<button
6794
onClick={triggerFileInput}
68-
className="text-sm"
95+
className="text-sm cursor-pointer"
6996
>
7097
Upload Image
7198
</button>
@@ -78,8 +105,20 @@ const PictureInput: FC = () => {
78105
</Button>
79106
)}
80107
</div>
108+
{imageModal && (
109+
<ImageModal
110+
image={image}
111+
crop={crop}
112+
setCrop={setCrop}
113+
zoom={zoom}
114+
setZoom={setZoom}
115+
onCropComplete={onCropComplete}
116+
handleSave={handleSaveImage}
117+
closeModal={() => setImageModal(false)}
118+
/>
119+
)}
81120
</div>
82121
);
83122
};
84123

85-
export default PictureInput;
124+
export default PictureInput;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Area } from "react-easy-crop";
2+
3+
export const getCroppedImg = async (
4+
imageSrc: string,
5+
croppedAreaPixels: Area
6+
):Promise<string> => {
7+
const image = await createImage(imageSrc as string);
8+
const canvas = document.createElement("canvas");
9+
const ctx = canvas.getContext("2d");
10+
11+
if (!ctx) throw new Error("Could not get canvas context");
12+
13+
canvas.width = croppedAreaPixels.width;
14+
canvas.height = croppedAreaPixels.height;
15+
16+
ctx.drawImage(
17+
image,
18+
croppedAreaPixels.x,
19+
croppedAreaPixels.y,
20+
croppedAreaPixels.width,
21+
croppedAreaPixels.height,
22+
0,
23+
0,
24+
croppedAreaPixels.width,
25+
croppedAreaPixels.height
26+
);
27+
28+
return new Promise((resolve, reject) => {
29+
canvas.toBlob((blob) => {
30+
if (blob) {
31+
resolve(URL.createObjectURL(blob));
32+
} else {
33+
reject(new Error("Canvas toBlob failed"));
34+
}
35+
}, "image/jpeg");
36+
});
37+
};
38+
39+
40+
export function createImage(url: string): Promise<HTMLImageElement> {
41+
return new Promise((resolve, reject) => {
42+
const img = new Image();
43+
img.crossOrigin = "anonymous";
44+
img.src = url;
45+
46+
img.onload = () => resolve(img);
47+
img.onerror = (error) => reject(error);
48+
});
49+
}

package-lock.json

+21
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)