11import { type BreadcrumbItem , type SharedData } from '@/types' ;
22import { Transition } from '@headlessui/react' ;
33import { Head , Link , router , useForm , usePage } from '@inertiajs/react' ;
4- import { FormEventHandler , useRef , useState } from 'react' ;
4+ import { FormEventHandler , useCallback , useEffect , useRef , useState } from 'react' ;
55import ReactCrop , { centerCrop , makeAspectCrop , type Crop } from 'react-image-crop' ;
66import 'react-image-crop/dist/ReactCrop.css' ;
77
@@ -16,7 +16,7 @@ import SettingsLayout from '@/layouts/settings/layout';
1616import { useInitials } from '@/hooks/use-initials' ;
1717import { Avatar , AvatarFallback , AvatarImage } from '@/components/ui/avatar' ;
1818import { 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
2121const 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