@@ -2,6 +2,8 @@ import { type BreadcrumbItem, type SharedData } from '@/types';
22import { Transition } from '@headlessui/react' ;
33import { Head , Link , router , useForm , usePage } from '@inertiajs/react' ;
44import { FormEventHandler , useRef , useState } from 'react' ;
5+ import ReactCrop , { type Crop } from 'react-image-crop' ;
6+ import 'react-image-crop/dist/ReactCrop.css' ;
57
68import DeleteUser from '@/components/delete-user' ;
79import HeadingSmall from '@/components/heading-small' ;
@@ -13,6 +15,7 @@ import AppLayout from '@/layouts/app-layout';
1315import SettingsLayout from '@/layouts/settings/layout' ;
1416import { useInitials } from '@/hooks/use-initials' ;
1517import { Avatar , AvatarFallback , AvatarImage } from '@/components/ui/avatar' ;
18+ import { Dialog , DialogContent , DialogHeader , DialogTitle } from '@/components/ui/dialog' ;
1619
1720const 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