Skip to content

Commit 9b1f32a

Browse files
committed
abstracting profile photo upload to it's own component
1 parent bb9f5d1 commit 9b1f32a

File tree

2 files changed

+309
-250
lines changed

2 files changed

+309
-250
lines changed
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { useCallback, useRef, useState } from 'react';
2+
import { router } from '@inertiajs/react';
3+
import ReactCrop, { centerCrop, makeAspectCrop, type Crop } from 'react-image-crop';
4+
import 'react-image-crop/dist/ReactCrop.css';
5+
import InputError from '@/components/input-error';
6+
import { Button } from '@/components/ui/button';
7+
import { Input } from '@/components/ui/input';
8+
import { Label } from '@/components/ui/label';
9+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
10+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
11+
import { CropIcon, Loader2, Trash2Icon, UploadIcon } from 'lucide-react';
12+
import { useInitials } from '@/hooks/use-initials';
13+
14+
type ProfilePhotoProps = {
15+
userName: string;
16+
userAvatar: string | null;
17+
onPhotoChange: (photo: File | null) => void;
18+
error?: string;
19+
};
20+
21+
export function ProfilePhoto({ userName, userAvatar, onPhotoChange, error }: ProfilePhotoProps) {
22+
const getInitials = useInitials();
23+
24+
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
25+
const [originalImage, setOriginalImage] = useState<string | null>(null);
26+
const [isCropperOpen, setIsCropperOpen] = useState<boolean>(false);
27+
const [isProcessing, setIsProcessing] = useState<boolean>(false);
28+
const [hasUserAdjustedCrop, setHasUserAdjustedCrop] = useState<boolean>(false);
29+
const [crop, setCrop] = useState<Crop>({
30+
unit: '%',
31+
width: 100,
32+
height: 100,
33+
x: 0,
34+
y: 0,
35+
});
36+
37+
// Default aspect ratio for profile photos
38+
const ASPECT_RATIO = 1;
39+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB limit
40+
const MAX_SIZE = 500; // Max output size for profile photos
41+
42+
const photoInput = useRef<HTMLInputElement | null>(null);
43+
const imageRef = useRef<HTMLImageElement | null>(null);
44+
45+
const selectNewPhoto = () => {
46+
photoInput.current?.click();
47+
};
48+
49+
const updatePhotoPreview = () => {
50+
const photo = photoInput.current?.files?.[0];
51+
52+
if (!photo) return;
53+
54+
// Validate file type and size
55+
if (!photo.type.startsWith('image/')) {
56+
alert('Please select an image file');
57+
clearPhotoFileInput();
58+
return;
59+
}
60+
61+
if (photo.size > MAX_FILE_SIZE) {
62+
alert('Image size should be less than 5MB');
63+
clearPhotoFileInput();
64+
return;
65+
}
66+
67+
const reader = new FileReader();
68+
69+
reader.onload = (e: ProgressEvent<FileReader>) => {
70+
const result = e.target?.result as string;
71+
setOriginalImage(result);
72+
setIsCropperOpen(true);
73+
};
74+
75+
reader.readAsDataURL(photo);
76+
};
77+
78+
const deletePhoto = () => {
79+
router.delete(route('profile-photo.destroy'), {
80+
preserveScroll: true,
81+
onSuccess: () => {
82+
setPhotoPreview(null);
83+
setOriginalImage(null);
84+
clearPhotoFileInput();
85+
onPhotoChange(null);
86+
},
87+
});
88+
};
89+
90+
const clearPhotoFileInput = () => {
91+
if (photoInput.current) {
92+
photoInput.current.value = '';
93+
}
94+
};
95+
96+
// Create a centered crop with the specified aspect ratio
97+
const onImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
98+
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
99+
100+
const crop = centerCrop(
101+
makeAspectCrop(
102+
{
103+
unit: '%',
104+
width: 90, // Slightly smaller initial crop for better visibility
105+
},
106+
ASPECT_RATIO,
107+
width,
108+
height
109+
),
110+
width,
111+
height
112+
);
113+
114+
setCrop(crop);
115+
setHasUserAdjustedCrop(false);
116+
}, []);
117+
118+
// Process the cropped image and convert to a File object
119+
const completeCrop = async () => {
120+
if (!imageRef.current || !crop.width || !crop.height) return;
121+
122+
try {
123+
setIsProcessing(true);
124+
125+
// Create a canvas with the crop dimensions
126+
const canvas = document.createElement('canvas');
127+
const scaleX = imageRef.current.naturalWidth / imageRef.current.width;
128+
const scaleY = imageRef.current.naturalHeight / imageRef.current.height;
129+
const pixelRatio = window.devicePixelRatio;
130+
131+
// Calculate dimensions while maintaining aspect ratio
132+
let cropX, cropY, cropWidth, cropHeight;
133+
134+
if (hasUserAdjustedCrop) {
135+
// User has manually adjusted the crop - use the crop values directly
136+
cropX = crop.x * scaleX;
137+
cropY = crop.y * scaleY;
138+
cropWidth = crop.width * scaleX;
139+
cropHeight = crop.height * scaleY;
140+
} else {
141+
// User hasn't adjusted the crop - use centered crop calculation
142+
const { naturalWidth, naturalHeight } = imageRef.current;
143+
144+
// Calculate a centered crop at 90% of the image size
145+
const size = Math.min(naturalWidth, naturalHeight) * 0.9;
146+
cropX = (naturalWidth - size) / 2;
147+
cropY = (naturalHeight - size) / 2;
148+
cropWidth = size;
149+
cropHeight = size;
150+
}
151+
152+
let targetWidth = cropWidth;
153+
let targetHeight = cropHeight;
154+
155+
if (cropWidth > MAX_SIZE || cropHeight > MAX_SIZE) {
156+
if (cropWidth > cropHeight) {
157+
targetWidth = MAX_SIZE;
158+
targetHeight = (cropHeight / cropWidth) * MAX_SIZE;
159+
} else {
160+
targetHeight = MAX_SIZE;
161+
targetWidth = (cropWidth / cropHeight) * MAX_SIZE;
162+
}
163+
}
164+
165+
canvas.width = targetWidth * pixelRatio;
166+
canvas.height = targetHeight * pixelRatio;
167+
168+
const ctx = canvas.getContext('2d');
169+
if (!ctx) {
170+
throw new Error('Could not get canvas context');
171+
}
172+
173+
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
174+
ctx.imageSmoothingQuality = 'high';
175+
176+
// Draw the cropped image to the canvas
177+
ctx.drawImage(
178+
imageRef.current,
179+
cropX,
180+
cropY,
181+
cropWidth,
182+
cropHeight,
183+
0,
184+
0,
185+
targetWidth,
186+
targetHeight
187+
);
188+
189+
// Convert canvas to a compressed JPEG data URL
190+
const croppedImageUrl = canvas.toDataURL('image/jpeg', 0.85); // 85% quality
191+
setPhotoPreview(croppedImageUrl);
192+
193+
// Convert data URL to Blob
194+
const response = await fetch(croppedImageUrl);
195+
const blob = await response.blob();
196+
197+
// Create a File from Blob with a meaningful name
198+
const originalFileName = photoInput.current?.files?.[0]?.name || 'profile-photo.jpg';
199+
const fileNameBase = originalFileName.substring(0, originalFileName.lastIndexOf('.')) || 'profile-photo';
200+
const croppedFile = new File([blob], `${fileNameBase}-cropped.jpg`, { type: 'image/jpeg' });
201+
202+
onPhotoChange(croppedFile);
203+
setIsCropperOpen(false);
204+
} catch (error) {
205+
console.error('Error processing cropped image:', error);
206+
alert('There was an error processing your image. Please try again.');
207+
} finally {
208+
setIsProcessing(false);
209+
}
210+
};
211+
212+
const cancelCrop = () => {
213+
setIsCropperOpen(false);
214+
clearPhotoFileInput();
215+
};
216+
217+
return (
218+
<>
219+
<div className="grid gap-2">
220+
<Label htmlFor="photo">Photo</Label>
221+
222+
<Input
223+
type="file"
224+
ref={photoInput}
225+
id="photo"
226+
className="hidden"
227+
onChange={updatePhotoPreview}
228+
accept="image/*"
229+
/>
230+
231+
<div className="flex items-center gap-4">
232+
<Avatar className="h-20 w-20">
233+
<AvatarImage src={photoPreview || userAvatar || ''} alt={userName} />
234+
<AvatarFallback>{getInitials(userName)}</AvatarFallback>
235+
</Avatar>
236+
237+
<Button type="button" variant="outline" onClick={selectNewPhoto}>
238+
<UploadIcon className="mr-0.5 h-4 w-4" />
239+
{userAvatar ? 'Change Photo' : 'Upload Photo'}
240+
</Button>
241+
242+
{(userAvatar || photoPreview) && (
243+
<Button type="button" variant="outline" onClick={deletePhoto}>
244+
<Trash2Icon className="mr-0.5 h-4 w-4" />
245+
Remove Photo
246+
</Button>
247+
)}
248+
</div>
249+
{error && <InputError className="mt-2" message={error} />}
250+
</div>
251+
252+
<Dialog open={isCropperOpen} onOpenChange={setIsCropperOpen}>
253+
<DialogContent className="sm:max-w-md">
254+
<DialogHeader>
255+
<DialogTitle>Crop your profile photo</DialogTitle>
256+
</DialogHeader>
257+
258+
<div className="mt-4 flex flex-col items-center gap-4">
259+
{originalImage && (
260+
<ReactCrop
261+
crop={crop}
262+
onChange={c => {
263+
setCrop(c);
264+
setHasUserAdjustedCrop(true);
265+
}}
266+
circularCrop
267+
aspect={ASPECT_RATIO}
268+
>
269+
<img
270+
ref={imageRef}
271+
src={originalImage}
272+
onLoad={onImageLoad}
273+
alt="Crop preview"
274+
className="max-h-96"
275+
/>
276+
</ReactCrop>
277+
)}
278+
279+
<div className="flex justify-end gap-2 w-full">
280+
<Button variant="outline" onClick={cancelCrop} disabled={isProcessing}>
281+
<Trash2Icon className="mr-2 h-4 w-4" /> Cancel
282+
</Button>
283+
<Button onClick={completeCrop} disabled={isProcessing}>
284+
{isProcessing ? (
285+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
286+
) : (
287+
<CropIcon className="mr-2 h-4 w-4" />
288+
)}
289+
{isProcessing ? 'Processing...' : 'Crop'}
290+
</Button>
291+
</div>
292+
</div>
293+
</DialogContent>
294+
</Dialog>
295+
</>
296+
);
297+
}

0 commit comments

Comments
 (0)