diff --git a/components/users/ImageCropper.tsx b/components/users/ImageCropper.tsx new file mode 100644 index 00000000..d17d2d35 --- /dev/null +++ b/components/users/ImageCropper.tsx @@ -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(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 => + 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 => { + 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 ( + !open && onCancel()}> + + + Crop Your Profile Picture + + +
+ {/* Cropper Area */} +
+ +
+ + {/* Zoom Control */} +
+
+ + + {Math.round(zoom * 100)}% + +
+
+ + setZoom(value[0])} + min={1} + max={3} + step={0.1} + className="flex-1" + /> + +
+
+ + {/* Instructions */} +
+ Drag to reposition • Use slider to zoom • Circular crop will be applied +
+
+ + + + + +
+
+ ) +} diff --git a/components/users/ProfilePictureUpload.tsx b/components/users/ProfilePictureUpload.tsx new file mode 100644 index 00000000..103c8f9e --- /dev/null +++ b/components/users/ProfilePictureUpload.tsx @@ -0,0 +1,239 @@ +'use client' + +import React, { useState, useRef } from 'react' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Loader2, X, AlertCircle, Crop } from 'lucide-react' +import { createClient } from '@/lib/supabase/client' +import { ImageCropper } from './ImageCropper' + +interface ProfilePictureUploadProps { + currentAvatarUrl?: string + userId: string + firstName?: string + lastName?: string + onUploadComplete: (avatarUrl: string) => void +} + +export function ProfilePictureUpload({ + currentAvatarUrl, + userId, + firstName, + lastName, + onUploadComplete +}: ProfilePictureUploadProps) { + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [previewUrl, setPreviewUrl] = useState(currentAvatarUrl || null) + const [selectedImage, setSelectedImage] = useState(null) + const [showCropper, setShowCropper] = useState(false) + const fileInputRef = useRef(null) + + const getInitials = () => { + if (firstName && lastName) { + return `${firstName[0]}${lastName[0]}` + } + if (firstName) { + return firstName[0].toUpperCase() + } + return 'U' + } + + const handleFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + // Validate file type + if (!file.type.startsWith('image/')) { + setError('Please select an image file') + return + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + setError('Image size must be less than 5MB') + return + } + + // Read file and show cropper + const reader = new FileReader() + reader.onload = () => { + setSelectedImage(reader.result as string) + setShowCropper(true) + setError(null) + } + reader.readAsDataURL(file) + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const handleCropComplete = async (croppedImageBlob: Blob) => { + try { + setUploading(true) + setShowCropper(false) + setError(null) + + const supabase = createClient() + + // Create a unique file name + const fileName = `${userId}-${Date.now()}.jpg` + const filePath = `avatars/${fileName}` + + // Upload cropped image to Supabase Storage + const { error: uploadError } = await supabase.storage + .from('profile-pictures') + .upload(filePath, croppedImageBlob, { + cacheControl: '3600', + upsert: true, + contentType: 'image/jpeg' + }) + + if (uploadError) { + throw uploadError + } + + // Get public URL + const { data: { publicUrl } } = supabase.storage + .from('profile-pictures') + .getPublicUrl(filePath) + + // Update preview + setPreviewUrl(publicUrl) + + // Update profile in database + const { error: updateError } = await supabase + .from('profiles') + .update({ avatar_url: publicUrl }) + .eq('id', userId) + + if (updateError) { + throw updateError + } + + // Notify parent component + onUploadComplete(publicUrl) + } catch (err) { + console.error('Error uploading avatar:', err) + setError(err instanceof Error ? err.message : 'Failed to upload image') + } finally { + setUploading(false) + setSelectedImage(null) + } + } + + const handleCropCancel = () => { + setShowCropper(false) + setSelectedImage(null) + } + + const handleRemoveAvatar = async () => { + try { + setUploading(true) + setError(null) + + const supabase = createClient() + + // Update profile to remove avatar + const { error: updateError } = await supabase + .from('profiles') + .update({ avatar_url: null }) + .eq('id', userId) + + if (updateError) { + throw updateError + } + + setPreviewUrl(null) + onUploadComplete('') + } catch (err) { + console.error('Error removing avatar:', err) + setError(err instanceof Error ? err.message : 'Failed to remove image') + } finally { + setUploading(false) + } + } + + return ( +
+ + +
+
+ + {previewUrl && } + + {getInitials()} + + + + {uploading && ( +
+ +
+ )} +
+ +
+ + + + + {previewUrl && ( + + )} + +

+ JPG, PNG or GIF. Max 5MB. +

+
+
+ + {error && ( + + + {error} + + )} + + {/* Image Cropper Dialog */} + {selectedImage && ( + + )} +
+ ) +} diff --git a/components/users/ProfileSettings.tsx b/components/users/ProfileSettings.tsx index 19e4c46b..cfba03b1 100644 --- a/components/users/ProfileSettings.tsx +++ b/components/users/ProfileSettings.tsx @@ -31,6 +31,7 @@ import { } from 'lucide-react' import { ProfileUpdateData } from '@/types/profile' import { UsernameField } from '@/components/UsernameField' +import { ProfilePictureUpload } from '@/components/users/ProfilePictureUpload' // Validation rules interface ValidationRule { @@ -152,7 +153,7 @@ export function ProfileSettings() { setFormData({ first_name: profile.first_name || '', last_name: profile.last_name || '', - + avatar_url: profile.avatar_url || '', bio: profile.bio || '', phone: profile.phone || '', github_url: profile.github_url || '', @@ -310,6 +311,22 @@ export function ProfileSettings() { Your personal information and contact details + {/* Profile Picture Upload */} + {profile && ( + { + setFormData(prev => ({ ...prev, avatar_url: avatarUrl })) + setHasUnsavedChanges(false) + }} + /> + )} + + +