Skip to content

Commit 7eb7843

Browse files
committed
feat: add image cropping functionality for profile photo upload
1 parent d5c3545 commit 7eb7843

File tree

3 files changed

+117
-5
lines changed

3 files changed

+117
-5
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"lucide-react": "^0.475.0",
5151
"react": "^19.0.0",
5252
"react-dom": "^19.0.0",
53+
"react-image-crop": "^11.0.7",
5354
"tailwind-merge": "^3.0.1",
5455
"tailwindcss": "^4.0.0",
5556
"tailwindcss-animate": "^1.0.7",

resources/js/pages/settings/profile.tsx

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { type BreadcrumbItem, type SharedData } from '@/types';
22
import { Transition } from '@headlessui/react';
33
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
44
import { FormEventHandler, useRef, useState } from 'react';
5+
import ReactCrop, { type Crop } from 'react-image-crop';
6+
import 'react-image-crop/dist/ReactCrop.css';
57

68
import DeleteUser from '@/components/delete-user';
79
import HeadingSmall from '@/components/heading-small';
@@ -13,6 +15,7 @@ import AppLayout from '@/layouts/app-layout';
1315
import SettingsLayout from '@/layouts/settings/layout';
1416
import { useInitials } from '@/hooks/use-initials';
1517
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
18+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
1619

1720
const breadcrumbs: BreadcrumbItem[] = [
1821
{
@@ -33,7 +36,18 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
3336
const getInitials = useInitials();
3437

3538
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
39+
const [originalImage, setOriginalImage] = useState<string | null>(null);
40+
const [isCropperOpen, setIsCropperOpen] = useState<boolean>(false);
41+
const [crop, setCrop] = useState<Crop>({
42+
unit: '%',
43+
width: 100,
44+
height: 100,
45+
x: 0,
46+
y: 0,
47+
});
48+
3649
const photoInput = useRef<HTMLInputElement | null>(null);
50+
const imageRef = useRef<HTMLImageElement | null>(null);
3751

3852
const { data, setData, post, errors, processing, recentlySuccessful } = useForm<Required<ProfileForm>>({
3953
_method: 'patch',
@@ -51,12 +65,12 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
5165

5266
if (!photo) return;
5367

54-
setData('photo', photo);
55-
5668
const reader = new FileReader();
5769

5870
reader.onload = (e: ProgressEvent<FileReader>) => {
59-
setPhotoPreview(e.target?.result as string);
71+
const result = e.target?.result as string;
72+
setOriginalImage(result);
73+
setIsCropperOpen(true);
6074
};
6175

6276
reader.readAsDataURL(photo);
@@ -67,6 +81,7 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
6781
preserveScroll: true,
6882
onSuccess: () => {
6983
setPhotoPreview(null);
84+
setOriginalImage(null);
7085
clearPhotoFileInput();
7186
},
7287
});
@@ -78,6 +93,56 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
7893
}
7994
};
8095

96+
const completeCrop = async () => {
97+
if (!imageRef.current || !crop.width || !crop.height) return;
98+
99+
const canvas = document.createElement('canvas');
100+
const scaleX = imageRef.current.naturalWidth / imageRef.current.width;
101+
const scaleY = imageRef.current.naturalHeight / imageRef.current.height;
102+
const pixelRatio = window.devicePixelRatio;
103+
104+
canvas.width = crop.width * scaleX * pixelRatio;
105+
canvas.height = crop.height * scaleY * pixelRatio;
106+
107+
const ctx = canvas.getContext('2d');
108+
if (!ctx) return;
109+
110+
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
111+
ctx.imageSmoothingQuality = 'high';
112+
113+
ctx.drawImage(
114+
imageRef.current,
115+
crop.x * scaleX,
116+
crop.y * scaleY,
117+
crop.width * scaleX,
118+
crop.height * scaleY,
119+
0,
120+
0,
121+
crop.width * scaleX,
122+
crop.height * scaleY
123+
);
124+
125+
// Convert canvas to blob
126+
const croppedImageUrl = canvas.toDataURL('image/jpeg');
127+
setPhotoPreview(croppedImageUrl);
128+
setIsCropperOpen(false);
129+
130+
// Convert data URL to Blob
131+
const response = await fetch(croppedImageUrl);
132+
const blob = await response.blob();
133+
134+
// Create a File from Blob
135+
const fileName = photoInput.current?.files?.[0]?.name || 'cropped-image.jpg';
136+
const croppedFile = new File([blob], fileName, { type: 'image/jpeg' });
137+
138+
setData('photo', croppedFile);
139+
};
140+
141+
const cancelCrop = () => {
142+
setIsCropperOpen(false);
143+
clearPhotoFileInput();
144+
};
145+
81146
const submit: FormEventHandler = (e) => {
82147
e.preventDefault();
83148

@@ -108,10 +173,10 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
108173
</Avatar>
109174

110175
<Button type="button" variant="outline" onClick={selectNewPhoto}>
111-
Select New Photo
176+
{auth.user.avatar ? 'Change Photo' : 'Upload Photo'}
112177
</Button>
113178

114-
{auth.user.avatar && (
179+
{(auth.user.avatar || photoPreview) && (
115180
<Button type="button" variant="outline" onClick={deletePhoto}>
116181
Remove Photo
117182
</Button>
@@ -192,6 +257,42 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
192257
</div>
193258

194259
<DeleteUser />
260+
261+
<Dialog open={isCropperOpen} onOpenChange={setIsCropperOpen}>
262+
<DialogContent className="sm:max-w-md">
263+
<DialogHeader>
264+
<DialogTitle>Crop your profile photo</DialogTitle>
265+
</DialogHeader>
266+
267+
<div className="mt-4 flex flex-col items-center gap-4">
268+
{originalImage && (
269+
<ReactCrop
270+
crop={crop}
271+
onChange={c => setCrop(c)}
272+
circularCrop
273+
aspect={1}
274+
>
275+
<img
276+
ref={imageRef}
277+
src={originalImage}
278+
alt="Crop preview"
279+
className="max-h-96"
280+
/>
281+
</ReactCrop>
282+
)}
283+
284+
<div className="flex justify-end gap-2 w-full">
285+
<Button variant="outline" onClick={cancelCrop}>
286+
Cancel
287+
</Button>
288+
<Button onClick={completeCrop}>
289+
Crop & Save
290+
</Button>
291+
</div>
292+
</div>
293+
</DialogContent>
294+
</Dialog>
295+
195296
</SettingsLayout>
196297
</AppLayout>
197298
);

0 commit comments

Comments
 (0)