diff --git a/src/components/upload/PhotoUpload.tsx b/src/components/upload/PhotoUpload.tsx index 69efc61e..195f14c7 100644 --- a/src/components/upload/PhotoUpload.tsx +++ b/src/components/upload/PhotoUpload.tsx @@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'; import { supabase } from '@/integrations/supabase/client'; interface PhotoUploadProps { - onUploadComplete?: (urls: string[]) => void; + onUploadComplete?: (urls: string[], imageId?: string) => void; onUploadStart?: () => void; onError?: (error: string) => void; maxFiles?: number; @@ -24,6 +24,7 @@ interface PhotoUploadProps { className?: string; variant?: 'default' | 'compact' | 'avatar'; accept?: string; + currentImageId?: string; // For cleanup of existing image } interface UploadedImage { @@ -41,7 +42,8 @@ export function PhotoUpload({ existingPhotos = [], className, variant = 'default', - accept = 'image/jpeg,image/png,image/webp' + accept = 'image/jpeg,image/png,image/webp', + currentImageId }: PhotoUploadProps) { const [uploadedImages, setUploadedImages] = useState([]); const [uploading, setUploading] = useState(false); @@ -164,6 +166,19 @@ export function PhotoUpload({ onUploadStart?.(); try { + // Delete old image first if this is an avatar update + if (isAvatar && currentImageId) { + try { + await supabase.functions.invoke('upload-image', { + method: 'DELETE', + body: { imageId: currentImageId } + }); + } catch (deleteError) { + console.warn('Failed to delete old avatar:', deleteError); + // Continue with upload even if deletion fails + } + } + const uploadPromises = filesToUpload.map(async (file, index) => { setUploadProgress((index / filesToUpload.length) * 100); return uploadFile(file); @@ -174,7 +189,7 @@ export function PhotoUpload({ if (isAvatar) { // For avatars, replace all existing images setUploadedImages(results); - onUploadComplete?.(results.map(img => img.url)); + onUploadComplete?.(results.map(img => img.url), results[0]?.id); } else { // For regular uploads, append to existing images setUploadedImages(prev => [...prev, ...results]); diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 883cf650..127156ec 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -286,6 +286,7 @@ export type Database = { } profiles: { Row: { + avatar_image_id: string | null avatar_url: string | null bio: string | null coaster_count: number | null @@ -305,6 +306,7 @@ export type Database = { username: string } Insert: { + avatar_image_id?: string | null avatar_url?: string | null bio?: string | null coaster_count?: number | null @@ -324,6 +326,7 @@ export type Database = { username: string } Update: { + avatar_image_id?: string | null avatar_url?: string | null bio?: string | null coaster_count?: number | null diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 580dbcd2..bc9ecb77 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -10,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuth } from '@/hooks/useAuth'; import { User, MapPin, @@ -32,6 +33,7 @@ export default function Profile() { const { username } = useParams<{ username?: string }>(); const navigate = useNavigate(); const { toast } = useToast(); + const { refreshProfile } = useAuth(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(false); @@ -41,6 +43,7 @@ export default function Profile() { bio: '', }); const [avatarUrl, setAvatarUrl] = useState(''); + const [avatarImageId, setAvatarImageId] = useState(''); useEffect(() => { getCurrentUser(); @@ -73,6 +76,7 @@ export default function Profile() { bio: data.bio || '', }); setAvatarUrl(data.avatar_url || ''); + setAvatarImageId(data.avatar_image_id || ''); } } catch (error: any) { console.error('Error fetching profile:', error); @@ -110,6 +114,7 @@ export default function Profile() { bio: data.bio || '', }); setAvatarUrl(data.avatar_url || ''); + setAvatarImageId(data.avatar_image_id || ''); } } catch (error: any) { console.error('Error fetching profile:', error); @@ -132,7 +137,8 @@ export default function Profile() { .update({ display_name: editForm.display_name, bio: editForm.bio, - avatar_url: avatarUrl + avatar_url: avatarUrl, + avatar_image_id: avatarImageId }) .eq('user_id', currentUser.id); @@ -142,7 +148,8 @@ export default function Profile() { ...prev, display_name: editForm.display_name, bio: editForm.bio, - avatar_url: avatarUrl + avatar_url: avatarUrl, + avatar_image_id: avatarImageId } : null); setEditing(false); @@ -159,6 +166,57 @@ export default function Profile() { } }; + const handleAvatarUpload = async (urls: string[], imageId?: string) => { + if (!currentUser || !urls[0]) return; + + const newAvatarUrl = urls[0]; + const newImageId = imageId || ''; + + // Update local state immediately + setAvatarUrl(newAvatarUrl); + setAvatarImageId(newImageId); + + try { + // Update database immediately + const { error } = await supabase + .from('profiles') + .update({ + avatar_url: newAvatarUrl, + avatar_image_id: newImageId + }) + .eq('user_id', currentUser.id); + + if (error) throw error; + + // Update local profile state + setProfile(prev => prev ? { + ...prev, + avatar_url: newAvatarUrl, + avatar_image_id: newImageId + } : null); + + // Refresh the auth context to update header avatar + if (refreshProfile) { + await refreshProfile(); + } + + toast({ + title: "Avatar updated", + description: "Your profile picture has been updated successfully.", + }); + } catch (error: any) { + // Revert local state on error + setAvatarUrl(profile?.avatar_url || ''); + setAvatarImageId(profile?.avatar_image_id || ''); + + toast({ + variant: "destructive", + title: "Error updating avatar", + description: error.message, + }); + } + }; + const isOwnProfile = currentUser && profile && currentUser.id === profile.user_id; if (loading) { @@ -212,7 +270,8 @@ export default function Profile() { variant="avatar" maxFiles={1} existingPhotos={profile.avatar_url ? [profile.avatar_url] : []} - onUploadComplete={(urls) => setAvatarUrl(urls[0] || '')} + onUploadComplete={handleAvatarUpload} + currentImageId={avatarImageId} onError={(error) => { toast({ title: "Upload Error", diff --git a/src/types/database.ts b/src/types/database.ts index d53e3c1d..b705c03d 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -97,6 +97,7 @@ export interface Profile { display_name?: string; bio?: string; avatar_url?: string; + avatar_image_id?: string; location?: Location; date_of_birth?: string; privacy_level: 'public' | 'friends' | 'private'; diff --git a/supabase/functions/upload-image/index.ts b/supabase/functions/upload-image/index.ts index 105db259..2c540224 100644 --- a/supabase/functions/upload-image/index.ts +++ b/supabase/functions/upload-image/index.ts @@ -20,6 +20,54 @@ serve(async (req) => { throw new Error('Missing Cloudflare credentials') } + if (req.method === 'DELETE') { + // Delete image from Cloudflare + const { imageId } = await req.json() + + if (!imageId) { + return new Response( + JSON.stringify({ error: 'Image ID is required for deletion' }), + { + status: 400, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + + const deleteResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1/${imageId}`, + { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${CLOUDFLARE_IMAGES_API_TOKEN}`, + }, + } + ) + + const deleteResult = await deleteResponse.json() + + if (!deleteResponse.ok) { + console.error('Cloudflare delete error:', deleteResult) + return new Response( + JSON.stringify({ + error: 'Failed to delete image', + details: deleteResult.errors || deleteResult.error + }), + { + status: 500, + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + + return new Response( + JSON.stringify({ success: true, deleted: true }), + { + headers: { ...corsHeaders, 'Content-Type': 'application/json' } + } + ) + } + if (req.method === 'POST') { // Request a direct upload URL from Cloudflare const { metadata = {}, variant = 'public' } = await req.json().catch(() => ({})) diff --git a/supabase/migrations/20250928173605_3b990015-082a-4243-af9f-8d93a0fefe33.sql b/supabase/migrations/20250928173605_3b990015-082a-4243-af9f-8d93a0fefe33.sql new file mode 100644 index 00000000..a6cf96f1 --- /dev/null +++ b/supabase/migrations/20250928173605_3b990015-082a-4243-af9f-8d93a0fefe33.sql @@ -0,0 +1,3 @@ +-- Add avatar_image_id column to profiles table for Cloudflare image management +ALTER TABLE public.profiles +ADD COLUMN avatar_image_id TEXT; \ No newline at end of file