Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add image crop in profile #143

Merged
merged 4 commits into from
Mar 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-easy-crop": "^5.4.1",
"react-hook-form": "^7.54.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.0.1",
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/components/user/editProfile/ImageModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FC } from 'react'
import Cropper, { Area } from 'react-easy-crop';

interface ImageModalProps {
image: string | null;
crop: { x: number; y: number };
setCrop: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
zoom: number;
setZoom: React.Dispatch<React.SetStateAction<number>>;
onCropComplete: (croppedArea: Area,
croppedAreaPixels: Area) => void;
handleSave: () => void;
closeModal: () => void;
}

const ImageModal:FC<ImageModalProps>= ({
image,
crop,
setCrop,
zoom,
setZoom,
onCropComplete,
handleSave,
closeModal
}) => {
return (
<div className="fixed inset-0 bg-black flex flex-col gap-5
items-center justify-center z-50">
<div className="bg-white p-5 rounded-lg shadow-lg w-96 h-96 relative">
<Cropper
image={image ?? undefined}
crop={crop}
zoom={zoom}
aspect={1}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>

</div>
<div className="flex justify-between gap-7">
<button onClick={() => handleSave()} className="bg-blue-800 text-white hover:bg-neutral-300 dark:bg-blue-800
hover:text-gray-700 dark:text-gray-200 rounded-full p-1 dark:hover:bg-gray-800
px-4 py-2 cursor-pointer">Save</button>
<button onClick={() => closeModal()} className="bg-red-800 text-white hover:bg-neutral-300 dark:bg-red-800
hover:text-gray-700 dark:text-gray-200 rounded-full p-1 dark:hover:bg-gray-800
px-4 py-2 cursor-pointer">Cancel</button>
</div>
</div>
)
}

export default ImageModal;
59 changes: 49 additions & 10 deletions frontend/src/components/user/editProfile/PictureInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { FC, useState, useRef } from "react";
import { FC, useState, useRef, useCallback } from "react";
import ImageModal from "./ImageModal";
import { getCroppedImg } from "./cropImage";
import { Area } from "react-easy-crop";

const PictureInput: FC = () => {
const [image, setImage] = useState<string | null>(
"https://res.cloudinary.com/dwyxogyrk/image/upload/v1737433466/h0xf7zi0blmclfqrjeo7.png"
);
const [image, setImage] = useState<string | null>(null);
const [croppedImage, setCroppedImage] = useState<string | null>(null);
const [imageModal, setImageModal] = useState<boolean>(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);

// Handle file selection
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -16,8 +22,8 @@ const PictureInput: FC = () => {
const reader = new FileReader();
reader.onload = () => setImage(reader.result as string);
reader.readAsDataURL(file);
setImageModal(true);
}

// Reset input value to allow selecting the same file again
if (fileInputRef.current) {
fileInputRef.current.value = "";
Expand All @@ -30,7 +36,27 @@ const PictureInput: FC = () => {
};

// Remove selected image
const removeImage = () => setImage(null);
const removeImage = () => {
setImage(null);
setCroppedImage(null);
}

const onCropComplete = useCallback((_val: Area,
croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels);
}, []);

const handleSaveImage = async() => {
if (image && croppedAreaPixels){
try{
const croppedImage = await getCroppedImg(image, croppedAreaPixels);
setCroppedImage(croppedImage);
setImageModal(false);
} catch (e) {
console.error(e);
}
}
}

return (
<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">
Expand All @@ -43,8 +69,9 @@ const PictureInput: FC = () => {
</button>
)}

{image ? (
<img className="w-24 h-24 rounded-2xl object-cover border border-gray-300 dark:border-gray-700" src={image} alt="Profile" />
{croppedImage ? (
<img className="w-24 h-24 rounded-2xl object-cover border border-gray-300
dark:border-gray-700" src={croppedImage} alt="Profile" />
) : (
<div className="w-24 h-24 rounded-2xl bg-gray-400 dark:bg-gray-800 flex items-center justify-center text-white">
No Image
Expand All @@ -65,7 +92,7 @@ const PictureInput: FC = () => {
{/* Button to Trigger File Input */}
<button
onClick={triggerFileInput}
className="text-sm"
className="text-sm cursor-pointer"
>
Upload Image
</button>
Expand All @@ -78,8 +105,20 @@ const PictureInput: FC = () => {
</Button>
)}
</div>
{imageModal && (
<ImageModal
image={image}
crop={crop}
setCrop={setCrop}
zoom={zoom}
setZoom={setZoom}
onCropComplete={onCropComplete}
handleSave={handleSaveImage}
closeModal={() => setImageModal(false)}
/>
)}
</div>
);
};

export default PictureInput;
export default PictureInput;
49 changes: 49 additions & 0 deletions frontend/src/components/user/editProfile/cropImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Area } from "react-easy-crop";

export const getCroppedImg = async (
imageSrc: string,
croppedAreaPixels: Area
):Promise<string> => {
const image = await createImage(imageSrc as string);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

if (!ctx) throw new Error("Could not get canvas context");

canvas.width = croppedAreaPixels.width;
canvas.height = croppedAreaPixels.height;

ctx.drawImage(
image,
croppedAreaPixels.x,
croppedAreaPixels.y,
croppedAreaPixels.width,
croppedAreaPixels.height,
0,
0,
croppedAreaPixels.width,
croppedAreaPixels.height
);

return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(URL.createObjectURL(blob));
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/jpeg");
});
};


export function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.src = url;

img.onload = () => resolve(img);
img.onerror = (error) => reject(error);
});
}
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading