import { useState, useRef, useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Upload, X, Image as ImageIcon, AlertCircle, Camera, FileImage } from 'lucide-react'; import { cn } from '@/lib/utils'; import { getErrorMessage } from '@/lib/errorHandler'; import { supabase } from '@/lib/supabaseClient'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { useAuth } from '@/hooks/useAuth'; interface PhotoUploadProps { onUploadComplete?: (urls: string[], imageId?: string) => void; onUploadStart?: () => void; onError?: (error: string) => void; maxFiles?: number; existingPhotos?: string[]; className?: string; variant?: 'default' | 'compact' | 'avatar'; accept?: string; currentImageId?: string; // For cleanup of existing image maxSizeMB?: number; // Custom max file size in MB } interface UploadedImage { id: string; url: string; filename: string; thumbnailUrl: string; previewUrl?: string; } export function PhotoUpload({ onUploadComplete, onUploadStart, onError, maxFiles = 5, existingPhotos = [], className, variant = 'default', accept = 'image/jpeg,image/png,image/webp', currentImageId, maxSizeMB = 10 // Default 10MB, but can be overridden }: PhotoUploadProps) { const [uploadedImages, setUploadedImages] = useState([]); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [dragOver, setDragOver] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); const objectUrlsRef = useRef>(new Set()); const isAvatar = variant === 'avatar'; const isCompact = variant === 'compact'; const actualMaxFiles = isAvatar ? 1 : maxFiles; const totalImages = uploadedImages.length + existingPhotos.length; const canUploadMore = totalImages < actualMaxFiles; useEffect(() => { return () => { objectUrlsRef.current.forEach(url => { try { URL.revokeObjectURL(url); } catch { // Silent cleanup failure - non-critical } }); objectUrlsRef.current.clear(); }; }, []); const createObjectUrl = (file: File): string => { const url = URL.createObjectURL(file); objectUrlsRef.current.add(url); return url; }; const revokeObjectUrl = (url: string) => { if (objectUrlsRef.current.has(url)) { try { URL.revokeObjectURL(url); objectUrlsRef.current.delete(url); } catch { // Silent cleanup failure - non-critical } } }; const validateFile = (file: File): string | null => { // Check file size using configurable limit const maxSize = maxSizeMB * 1024 * 1024; if (file.size > maxSize) { return `File size must be less than ${maxSizeMB}MB`; } // Check file type const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedTypes.includes(file.type)) { return 'Only JPEG, PNG, and WebP images are allowed'; } return null; }; const uploadFile = async (file: File, previewUrl: string): Promise => { try { const { data: uploadData, error: uploadError, requestId } = await invokeWithTracking( 'upload-image', { metadata: { filename: file.name, size: file.size, type: file.type, uploadedAt: new Date().toISOString() }, variant: isAvatar ? 'avatar' : 'public' }, undefined ); if (uploadError) { revokeObjectUrl(previewUrl); throw new Error(uploadError.message); } if (!uploadData?.success) { revokeObjectUrl(previewUrl); throw new Error(uploadData?.error || 'Failed to get upload URL'); } const { uploadURL, id } = uploadData; const formData = new FormData(); formData.append('file', file); const uploadResponse = await fetch(uploadURL, { method: 'POST', body: formData, }); if (!uploadResponse.ok) { revokeObjectUrl(previewUrl); throw new Error('Direct upload to Cloudflare failed'); } // Fetch session token once before polling const sessionData = await supabase.auth.getSession(); const accessToken = sessionData.data.session?.access_token; if (!accessToken) { revokeObjectUrl(previewUrl); throw new Error('Authentication required for upload'); } const maxAttempts = 60; let attempts = 0; const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://api.thrillwiki.com'; while (attempts < maxAttempts) { try { const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, { method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); if (response.ok) { const statusData = await response.json(); if (statusData?.success && statusData.uploaded && statusData.urls) { const imageUrl = isAvatar ? statusData.urls.avatar : statusData.urls.public; const thumbUrl = isAvatar ? statusData.urls.avatar : statusData.urls.thumbnail; revokeObjectUrl(previewUrl); return { id: statusData.id, url: imageUrl, filename: file.name, thumbnailUrl: thumbUrl, previewUrl: undefined }; } } } catch { // Status poll error - will retry } await new Promise(resolve => setTimeout(resolve, 500)); attempts++; } revokeObjectUrl(previewUrl); throw new Error('Upload timeout - image processing took too long'); } catch (error: unknown) { revokeObjectUrl(previewUrl); throw error; } }; const handleFiles = async (files: FileList) => { if (!isAvatar && !canUploadMore) { setError(`Maximum ${actualMaxFiles} ${actualMaxFiles === 1 ? 'image' : 'images'} allowed`); onError?.(`Maximum ${actualMaxFiles} ${actualMaxFiles === 1 ? 'image' : 'images'} allowed`); return; } const filesToUpload = isAvatar ? Array.from(files).slice(0, 1) : Array.from(files).slice(0, actualMaxFiles - totalImages); if (filesToUpload.length === 0) { setError('No files to upload'); onError?.('No files to upload'); return; } for (const file of filesToUpload) { const validationError = validateFile(file); if (validationError) { setError(validationError); onError?.(validationError); return; } } setUploading(true); setError(null); onUploadStart?.(); const previewUrls: string[] = []; try { if (isAvatar && currentImageId) { try { await invokeWithTracking( 'upload-image', { imageId: currentImageId }, undefined, 'DELETE' ); } catch { // Old avatar deletion failed - non-critical } } const uploadPromises = filesToUpload.map(async (file, index) => { setUploadProgress((index / filesToUpload.length) * 100); const previewUrl = createObjectUrl(file); previewUrls.push(previewUrl); return uploadFile(file, previewUrl); }); const results = await Promise.all(uploadPromises); if (isAvatar) { setUploadedImages(results); onUploadComplete?.(results.map(img => img.url), results[0]?.id); } else { setUploadedImages(prev => [...prev, ...results]); const allUrls = [...existingPhotos, ...uploadedImages.map(img => img.url), ...results.map(img => img.url)]; onUploadComplete?.(allUrls); } setUploadProgress(100); } catch (error: unknown) { previewUrls.forEach(url => revokeObjectUrl(url)); const errorMsg = getErrorMessage(error); setError(errorMsg); onError?.(errorMsg); } finally { setUploading(false); setUploadProgress(0); } }; const removeImage = (imageId: string) => { const imageToRemove = uploadedImages.find(img => img.id === imageId); if (imageToRemove?.previewUrl) { revokeObjectUrl(imageToRemove.previewUrl); } setUploadedImages(prev => prev.filter(img => img.id !== imageId)); const updatedUrls = [...existingPhotos, ...uploadedImages.filter(img => img.id !== imageId).map(img => img.url)]; onUploadComplete?.(updatedUrls); }; const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setDragOver(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setDragOver(false); if (!(isAvatar || canUploadMore) || uploading) return; const files = e.dataTransfer.files; if (files.length > 0) { handleFiles(files); } }, [canUploadMore, uploading]); const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { handleFiles(files); } }; const triggerFileSelect = () => { fileInputRef.current?.click(); }; if (isAvatar) { return (
{uploadedImages.length > 0 ? ( Avatar { e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+'; }} /> ) : existingPhotos.length > 0 ? ( Avatar ) : (
)} {uploading && (
)}

JPEG, PNG, WebP up to {maxSizeMB}MB

{error && ( {error} )}
); } return (
{/* Upload Area */} {uploading ? (

Uploading...

) : (
{dragOver ? ( ) : ( )}

{isAvatar ? 'Upload Avatar' : canUploadMore ? 'Upload Photos' : 'Maximum Photos Reached'}

{isAvatar ? ( <>Drag & drop or click to browse
JPEG, PNG, WebP up to {maxSizeMB}MB ) : canUploadMore ? ( <>Drag & drop or click to browse
JPEG, PNG, WebP up to {maxSizeMB}MB each ) : ( `Maximum ${actualMaxFiles} ${actualMaxFiles === 1 ? 'photo' : 'photos'} allowed` )}

{!isAvatar && canUploadMore && ( {totalImages}/{actualMaxFiles} {actualMaxFiles === 1 ? 'photo' : 'photos'} )}
)}
1} onChange={handleFileSelect} className="hidden" disabled={!(isAvatar || canUploadMore) || uploading} /> {/* Error Display */} {error && ( {error} )} {/* Uploaded Images Preview */} {uploadedImages.length > 0 && (

Uploaded Photos

{uploadedImages.map((image) => (
{image.filename} { e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+'; }} />
{image.filename}
))}
)} {/* Existing Photos Display */} {existingPhotos.length > 0 && (

Existing Photos

{existingPhotos.map((url, index) => (
{`Existing { e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+'; }} /> Existing
))}
)}
); }