Skip to content

Commit bb9f5d1

Browse files
committed
Added a bit more error handling and refactoring
1 parent 0276ad4 commit bb9f5d1

File tree

1 file changed

+116
-57
lines changed

1 file changed

+116
-57
lines changed

resources/js/pages/settings/profile.tsx

Lines changed: 116 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type BreadcrumbItem, type SharedData } from '@/types';
22
import { Transition } from '@headlessui/react';
33
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
4-
import { FormEventHandler, useRef, useState } from 'react';
4+
import { FormEventHandler, useCallback, useEffect, useRef, useState } from 'react';
55
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
66
import 'react-image-crop/dist/ReactCrop.css';
77

@@ -16,7 +16,7 @@ import SettingsLayout from '@/layouts/settings/layout';
1616
import { useInitials } from '@/hooks/use-initials';
1717
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1818
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
19-
import { CropIcon, Trash2Icon } from 'lucide-react';
19+
import { CropIcon, Loader2, Trash2Icon, UploadIcon } from 'lucide-react';
2020

2121
const breadcrumbs: BreadcrumbItem[] = [
2222
{
@@ -39,14 +39,18 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
3939
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
4040
const [originalImage, setOriginalImage] = useState<string | null>(null);
4141
const [isCropperOpen, setIsCropperOpen] = useState<boolean>(false);
42+
const [isProcessing, setIsProcessing] = useState<boolean>(false);
4243
const [crop, setCrop] = useState<Crop>({
4344
unit: '%',
4445
width: 100,
4546
height: 100,
4647
x: 0,
4748
y: 0,
4849
});
49-
50+
51+
// Default aspect ratio for profile photos
52+
const ASPECT_RATIO = 1;
53+
5054
const photoInput = useRef<HTMLInputElement | null>(null);
5155
const imageRef = useRef<HTMLImageElement | null>(null);
5256

@@ -65,6 +69,21 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
6569
const photo = photoInput.current?.files?.[0];
6670

6771
if (!photo) return;
72+
73+
// Validate file type and size
74+
if (!photo.type.startsWith('image/')) {
75+
alert('Please select an image file');
76+
clearPhotoFileInput();
77+
return;
78+
}
79+
80+
// 5MB limit
81+
const MAX_FILE_SIZE = 5 * 1024 * 1024;
82+
if (photo.size > MAX_FILE_SIZE) {
83+
alert('Image size should be less than 5MB');
84+
clearPhotoFileInput();
85+
return;
86+
}
6887

6988
const reader = new FileReader();
7089

@@ -94,71 +113,104 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
94113
}
95114
};
96115

97-
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
98-
const { naturalWidth: width, naturalHeight: height } = e.currentTarget
116+
// Create a centered crop with the specified aspect ratio
117+
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
118+
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
99119

100120
const crop = centerCrop(
101121
makeAspectCrop(
102122
{
103-
// You don't need to pass a complete crop into
104-
// makeAspectCrop or centerCrop.
105123
unit: '%',
106-
width: 100,
124+
width: 90, // Slightly smaller initial crop for better visibility
107125
},
108-
1 / 1,
126+
ASPECT_RATIO,
109127
width,
110128
height
111129
),
112130
width,
113131
height
114-
)
132+
);
115133

116-
setCrop(crop)
117-
}
134+
setCrop(crop);
135+
}, []);
118136

137+
// Process the cropped image and convert to a File object
119138
const completeCrop = async () => {
120139
if (!imageRef.current || !crop.width || !crop.height) return;
121-
122-
const canvas = document.createElement('canvas');
123-
const scaleX = imageRef.current.naturalWidth / imageRef.current.width;
124-
const scaleY = imageRef.current.naturalHeight / imageRef.current.height;
125-
const pixelRatio = window.devicePixelRatio;
126-
127-
canvas.width = crop.width * scaleX * pixelRatio;
128-
canvas.height = crop.height * scaleY * pixelRatio;
129-
130-
const ctx = canvas.getContext('2d');
131-
if (!ctx) return;
132-
133-
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
134-
ctx.imageSmoothingQuality = 'high';
135-
136-
ctx.drawImage(
137-
imageRef.current,
138-
crop.x * scaleX,
139-
crop.y * scaleY,
140-
crop.width * scaleX,
141-
crop.height * scaleY,
142-
0,
143-
0,
144-
crop.width * scaleX,
145-
crop.height * scaleY
146-
);
147-
148-
// Convert canvas to blob
149-
const croppedImageUrl = canvas.toDataURL('image/jpeg');
150-
setPhotoPreview(croppedImageUrl);
151-
setIsCropperOpen(false);
152-
153-
// Convert data URL to Blob
154-
const response = await fetch(croppedImageUrl);
155-
const blob = await response.blob();
156-
157-
// Create a File from Blob
158-
const fileName = photoInput.current?.files?.[0]?.name || 'cropped-image.jpg';
159-
const croppedFile = new File([blob], fileName, { type: 'image/jpeg' });
160-
161-
setData('photo', croppedFile);
140+
141+
try {
142+
setIsProcessing(true);
143+
144+
// Create a canvas with the crop dimensions
145+
const canvas = document.createElement('canvas');
146+
const scaleX = imageRef.current.naturalWidth / imageRef.current.width;
147+
const scaleY = imageRef.current.naturalHeight / imageRef.current.height;
148+
const pixelRatio = window.devicePixelRatio;
149+
150+
// Set dimensions with a reasonable max size for profile photos (e.g., 500x500)
151+
const MAX_SIZE = 500;
152+
const cropWidth = crop.width * scaleX;
153+
const cropHeight = crop.height * scaleY;
154+
155+
// Calculate dimensions while maintaining aspect ratio
156+
let targetWidth = cropWidth;
157+
let targetHeight = cropHeight;
158+
159+
if (cropWidth > MAX_SIZE || cropHeight > MAX_SIZE) {
160+
if (cropWidth > cropHeight) {
161+
targetWidth = MAX_SIZE;
162+
targetHeight = (cropHeight / cropWidth) * MAX_SIZE;
163+
} else {
164+
targetHeight = MAX_SIZE;
165+
targetWidth = (cropWidth / cropHeight) * MAX_SIZE;
166+
}
167+
}
168+
169+
canvas.width = targetWidth * pixelRatio;
170+
canvas.height = targetHeight * pixelRatio;
171+
172+
const ctx = canvas.getContext('2d');
173+
if (!ctx) {
174+
throw new Error('Could not get canvas context');
175+
}
176+
177+
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
178+
ctx.imageSmoothingQuality = 'high';
179+
180+
// Draw the cropped image to the canvas
181+
ctx.drawImage(
182+
imageRef.current,
183+
crop.x * scaleX,
184+
crop.y * scaleY,
185+
cropWidth,
186+
cropHeight,
187+
0,
188+
0,
189+
targetWidth,
190+
targetHeight
191+
);
192+
193+
// Convert canvas to a compressed JPEG data URL
194+
const croppedImageUrl = canvas.toDataURL('image/jpeg', 0.85); // 85% quality
195+
setPhotoPreview(croppedImageUrl);
196+
197+
// Convert data URL to Blob
198+
const response = await fetch(croppedImageUrl);
199+
const blob = await response.blob();
200+
201+
// Create a File from Blob with a meaningful name
202+
const originalFileName = photoInput.current?.files?.[0]?.name || 'profile-photo.jpg';
203+
const fileNameBase = originalFileName.substring(0, originalFileName.lastIndexOf('.')) || 'profile-photo';
204+
const croppedFile = new File([blob], `${fileNameBase}-cropped.jpg`, { type: 'image/jpeg' });
205+
206+
setData('photo', croppedFile);
207+
setIsCropperOpen(false);
208+
} catch (error) {
209+
console.error('Error processing cropped image:', error);
210+
alert('There was an error processing your image. Please try again.');
211+
} finally {
212+
setIsProcessing(false);
213+
}
162214
};
163215

164216
const cancelCrop = () => {
@@ -196,11 +248,13 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
196248
</Avatar>
197249

198250
<Button type="button" variant="outline" onClick={selectNewPhoto}>
251+
<UploadIcon className="mr-0.5 h-4 w-4" />
199252
{auth.user.avatar ? 'Change Photo' : 'Upload Photo'}
200253
</Button>
201254

202255
{(auth.user.avatar || photoPreview) && (
203256
<Button type="button" variant="outline" onClick={deletePhoto}>
257+
<Trash2Icon className="mr-0.5 h-4 w-4" />
204258
Remove Photo
205259
</Button>
206260
)}
@@ -306,11 +360,16 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
306360
)}
307361

308362
<div className="flex justify-end gap-2 w-full">
309-
<Button variant="outline" onClick={cancelCrop}>
310-
<Trash2Icon /> Cancel
363+
<Button variant="outline" onClick={cancelCrop} disabled={isProcessing}>
364+
<Trash2Icon className="mr-2 h-4 w-4" /> Cancel
311365
</Button>
312-
<Button onClick={completeCrop}>
313-
<CropIcon /> Crop
366+
<Button onClick={completeCrop} disabled={isProcessing}>
367+
{isProcessing ? (
368+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
369+
) : (
370+
<CropIcon className="mr-2 h-4 w-4" />
371+
)}
372+
{isProcessing ? 'Processing...' : 'Crop'}
314373
</Button>
315374
</div>
316375
</div>

0 commit comments

Comments
 (0)