import { useState, useRef, useCallback } from 'react' import Cropper from 'react-easy-crop' import { Upload, Trash2, Loader2, ZoomIn, ZoomOut, ArrowLeft } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' const MAX_MB = 5 const MAX_BYTES = MAX_MB * 1024 * 1024 const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image() img.addEventListener('load', () => resolve(img)) img.addEventListener('error', reject) img.setAttribute('crossOrigin', 'anonymous') img.src = src }) } async function cropToBlob(imageSrc, pixels, outputSize = 512) { const img = await loadImage(imageSrc) const canvas = document.createElement('canvas') canvas.width = outputSize canvas.height = outputSize const ctx = canvas.getContext('2d') ctx.drawImage(img, pixels.x, pixels.y, pixels.width, pixels.height, 0, 0, outputSize, outputSize) return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.93)) } export default function AvatarUploadDialog({ open, onClose, currentAvatarUrl = '', initials = '?', onUpload, onDelete, loading = false, }) { const [step, setStep] = useState('pick') // 'pick' | 'crop' const [imageSrc, setImageSrc] = useState(null) const [fileName, setFileName] = useState('') const [dragOver, setDragOver] = useState(false) const [error, setError] = useState('') const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) const inputRef = useRef(null) const reset = () => { setStep('pick') setImageSrc(null) setFileName('') setError('') setDragOver(false) setCrop({ x: 0, y: 0 }) setZoom(1) setCroppedAreaPixels(null) } const handleClose = () => { reset(); onClose() } const validate = (f) => { if (!ALLOWED.includes(f.type)) { setError('Unsupported format. Use JPEG, PNG, WebP or GIF.') return false } if (f.size > MAX_BYTES) { setError(`File too large — max is ${MAX_MB} MB.`) return false } return true } const applyFile = (f) => { setError('') if (!validate(f)) return const reader = new FileReader() reader.onload = (e) => { setImageSrc(e.target.result) setFileName(f.name) setCrop({ x: 0, y: 0 }) setZoom(1) setStep('crop') } reader.readAsDataURL(f) } const handleInputChange = (e) => { const f = e.target.files?.[0] if (f) applyFile(f) e.target.value = '' } const handleDrop = (e) => { e.preventDefault() setDragOver(false) const f = e.dataTransfer.files?.[0] if (f) applyFile(f) } const onCropComplete = useCallback((_, pixels) => { setCroppedAreaPixels(pixels) }, []) const handleUpload = async () => { if (!croppedAreaPixels) return const blob = await cropToBlob(imageSrc, croppedAreaPixels) const file = new File([blob], fileName || 'avatar.jpg', { type: 'image/jpeg' }) const result = await onUpload(file) if (result?.success) handleClose() } const handleDelete = async () => { const result = await onDelete() if (result?.success) handleClose() } return ( {/* ── Header ── */}
{step === 'crop' && ( )} {step === 'crop' ? 'Adjust photo' : 'Change Avatar'}
{/* ── Step: pick ── */} {step === 'pick' && (
{initials}
inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragOver(true) }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} className={cn( 'border-2 border-dashed rounded-xl px-6 py-6 text-center cursor-pointer transition-colors select-none', dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground/40 hover:bg-muted/40', error && !dragOver && 'border-destructive/60 bg-destructive/5' )} >

{dragOver ? 'Drop to select' : 'Click or drag & drop to upload'}

JPEG, PNG, WebP or GIF · max {MAX_MB} MB

{error && (

{error}

)}
)} {/* ── Step: crop ── */} {step === 'crop' && (
{/* Cropper canvas */}
{/* Zoom controls */}
setZoom(Number(e.target.value))} className="flex-1 h-1.5 appearance-none rounded-full bg-border cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:shadow-sm" />

Drag to reposition · Scroll or pinch to zoom

)} {/* ── Footer ── */} {/* mx-0 mb-0 rounded-none bg-transparent override DialogFooter's negative margins that assume p-4 on DialogContent — we use p-0 so those would overshoot */} {step === 'pick' && currentAvatarUrl && ( )} {step === 'crop' && ( )}
) }