Skip to content

Commit e2bc35a

Browse files
committed
feat(users): Add comprehensive profile picture upload and cropping functionality
- Implement ImageCropper component for precise image cropping - Create ProfilePictureUpload component with advanced file handling - Add support for image validation, preview, and cropping - Integrate with Supabase client for file upload - Enhance user experience with intuitive UI controls - Implement error handling and file type/size validation - Support circular avatar cropping with zoom and positioning controls
1 parent 5a0e01d commit e2bc35a

8 files changed

Lines changed: 526 additions & 3 deletions

File tree

components/users/ImageCropper.tsx

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
'use client'
2+
3+
import React, { useState, useCallback } from 'react'
4+
import Cropper from 'react-easy-crop'
5+
import { Button } from '@/components/ui/button'
6+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
7+
import { Slider } from '@/components/ui/slider'
8+
import { Loader2, ZoomIn, ZoomOut } from 'lucide-react'
9+
10+
interface ImageCropperProps {
11+
image: string
12+
onCropComplete: (croppedImage: Blob) => void
13+
onCancel: () => void
14+
isOpen: boolean
15+
}
16+
17+
interface Area {
18+
x: number
19+
y: number
20+
width: number
21+
height: number
22+
}
23+
24+
type CroppedAreaPixels = Area
25+
26+
export function ImageCropper({ image, onCropComplete, onCancel, isOpen }: ImageCropperProps) {
27+
const [crop, setCrop] = useState({ x: 0, y: 0 })
28+
const [zoom, setZoom] = useState(1)
29+
const [croppedAreaPixels, setCroppedAreaPixels] = useState<CroppedAreaPixels | null>(null)
30+
const [processing, setProcessing] = useState(false)
31+
32+
const onCropChange = (crop: { x: number; y: number }) => {
33+
setCrop(crop)
34+
}
35+
36+
const onZoomChange = (zoom: number) => {
37+
setZoom(zoom)
38+
}
39+
40+
const onCropCompleteCallback = useCallback(
41+
(croppedArea: Area, croppedAreaPixels: CroppedAreaPixels) => {
42+
setCroppedAreaPixels(croppedAreaPixels)
43+
},
44+
[]
45+
)
46+
47+
const createImage = (url: string): Promise<HTMLImageElement> =>
48+
new Promise((resolve, reject) => {
49+
const image = new Image()
50+
image.addEventListener('load', () => resolve(image))
51+
image.addEventListener('error', (error) => reject(error))
52+
image.src = url
53+
})
54+
55+
const getCroppedImg = async (
56+
imageSrc: string,
57+
pixelCrop: CroppedAreaPixels
58+
): Promise<Blob> => {
59+
const image = await createImage(imageSrc)
60+
const canvas = document.createElement('canvas')
61+
const ctx = canvas.getContext('2d')
62+
63+
if (!ctx) {
64+
throw new Error('No 2d context')
65+
}
66+
67+
// Set canvas size to the cropped area
68+
canvas.width = pixelCrop.width
69+
canvas.height = pixelCrop.height
70+
71+
// Draw the cropped image
72+
ctx.drawImage(
73+
image,
74+
pixelCrop.x,
75+
pixelCrop.y,
76+
pixelCrop.width,
77+
pixelCrop.height,
78+
0,
79+
0,
80+
pixelCrop.width,
81+
pixelCrop.height
82+
)
83+
84+
// Convert canvas to blob
85+
return new Promise((resolve, reject) => {
86+
canvas.toBlob((blob) => {
87+
if (blob) {
88+
resolve(blob)
89+
} else {
90+
reject(new Error('Canvas is empty'))
91+
}
92+
}, 'image/jpeg', 0.95)
93+
})
94+
}
95+
96+
const handleCropConfirm = async () => {
97+
if (!croppedAreaPixels) return
98+
99+
try {
100+
setProcessing(true)
101+
const croppedImage = await getCroppedImg(image, croppedAreaPixels)
102+
onCropComplete(croppedImage)
103+
} catch (error) {
104+
console.error('Error cropping image:', error)
105+
} finally {
106+
setProcessing(false)
107+
}
108+
}
109+
110+
return (
111+
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
112+
<DialogContent className="max-w-3xl">
113+
<DialogHeader>
114+
<DialogTitle>Crop Your Profile Picture</DialogTitle>
115+
</DialogHeader>
116+
117+
<div className="space-y-4">
118+
{/* Cropper Area */}
119+
<div className="relative h-[400px] bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden">
120+
<Cropper
121+
image={image}
122+
crop={crop}
123+
zoom={zoom}
124+
aspect={1}
125+
cropShape="round"
126+
showGrid={false}
127+
onCropChange={onCropChange}
128+
onZoomChange={onZoomChange}
129+
onCropComplete={onCropCompleteCallback}
130+
/>
131+
</div>
132+
133+
{/* Zoom Control */}
134+
<div className="space-y-2">
135+
<div className="flex items-center justify-between">
136+
<label className="text-sm font-medium flex items-center gap-2">
137+
<ZoomOut className="h-4 w-4" />
138+
Zoom
139+
</label>
140+
<span className="text-sm text-muted-foreground">
141+
{Math.round(zoom * 100)}%
142+
</span>
143+
</div>
144+
<div className="flex items-center gap-4">
145+
<ZoomOut className="h-4 w-4 text-muted-foreground" />
146+
<Slider
147+
value={[zoom]}
148+
onValueChange={(value) => setZoom(value[0])}
149+
min={1}
150+
max={3}
151+
step={0.1}
152+
className="flex-1"
153+
/>
154+
<ZoomIn className="h-4 w-4 text-muted-foreground" />
155+
</div>
156+
</div>
157+
158+
{/* Instructions */}
159+
<div className="text-sm text-muted-foreground text-center">
160+
Drag to reposition • Use slider to zoom • Circular crop will be applied
161+
</div>
162+
</div>
163+
164+
<DialogFooter>
165+
<Button
166+
variant="outline"
167+
onClick={onCancel}
168+
disabled={processing}
169+
>
170+
Cancel
171+
</Button>
172+
<Button
173+
onClick={handleCropConfirm}
174+
disabled={processing}
175+
className="gap-2"
176+
>
177+
{processing ? (
178+
<>
179+
<Loader2 className="h-4 w-4 animate-spin" />
180+
Processing...
181+
</>
182+
) : (
183+
'Apply Crop'
184+
)}
185+
</Button>
186+
</DialogFooter>
187+
</DialogContent>
188+
</Dialog>
189+
)
190+
}

0 commit comments

Comments
 (0)