Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions components/users/ImageCropper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
'use client'

import React, { useState, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Slider } from '@/components/ui/slider'
import { Loader2, ZoomIn, ZoomOut } from 'lucide-react'

interface ImageCropperProps {
image: string
onCropComplete: (croppedImage: Blob) => void
onCancel: () => void
isOpen: boolean
}

interface Area {
x: number
y: number
width: number
height: number
}

type CroppedAreaPixels = Area

export function ImageCropper({ image, onCropComplete, onCancel, isOpen }: ImageCropperProps) {
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<CroppedAreaPixels | null>(null)
const [processing, setProcessing] = useState(false)

const onCropChange = (crop: { x: number; y: number }) => {
setCrop(crop)
}

const onZoomChange = (zoom: number) => {
setZoom(zoom)
}

const onCropCompleteCallback = useCallback(
(croppedArea: Area, croppedAreaPixels: CroppedAreaPixels) => {
setCroppedAreaPixels(croppedAreaPixels)
},
[]
)

const createImage = (url: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', (error) => reject(error))
image.src = url
})

const getCroppedImg = async (
imageSrc: string,
pixelCrop: CroppedAreaPixels
): Promise<Blob> => {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

if (!ctx) {
throw new Error('No 2d context')
}

// Set canvas size to the cropped area
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height

// Draw the cropped image
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
pixelCrop.width,
pixelCrop.height
)

// Convert canvas to blob
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Canvas is empty'))
}
}, 'image/jpeg', 0.95)
})
}

const handleCropConfirm = async () => {
if (!croppedAreaPixels) return

try {
setProcessing(true)
const croppedImage = await getCroppedImg(image, croppedAreaPixels)
onCropComplete(croppedImage)
} catch (error) {
console.error('Error cropping image:', error)
} finally {
setProcessing(false)
}
}

return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Crop Your Profile Picture</DialogTitle>
</DialogHeader>

<div className="space-y-4">
{/* Cropper Area */}
<div className="relative h-[400px] bg-gray-100 dark:bg-gray-900 rounded-lg overflow-hidden">
<Cropper
image={image}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={onCropChange}
onZoomChange={onZoomChange}
onCropComplete={onCropCompleteCallback}
/>
</div>

{/* Zoom Control */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium flex items-center gap-2">
<ZoomOut className="h-4 w-4" />
Zoom
</label>
<span className="text-sm text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
</div>
<div className="flex items-center gap-4">
<ZoomOut className="h-4 w-4 text-muted-foreground" />
<Slider
value={[zoom]}
onValueChange={(value) => setZoom(value[0])}
min={1}
max={3}
step={0.1}
className="flex-1"
/>
<ZoomIn className="h-4 w-4 text-muted-foreground" />
</div>
</div>

{/* Instructions */}
<div className="text-sm text-muted-foreground text-center">
Drag to reposition • Use slider to zoom • Circular crop will be applied
</div>
</div>

<DialogFooter>
<Button
variant="outline"
onClick={onCancel}
disabled={processing}
>
Cancel
</Button>
<Button
onClick={handleCropConfirm}
disabled={processing}
className="gap-2"
>
{processing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
'Apply Crop'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading
Loading