Files
thrilltrack-explorer/src/components/upload/PhotoUpload.tsx
pac7 1addcbc0dd Improve error handling and environment configuration across the application
Enhance input validation, update environment variable usage for Supabase and Turnstile, and refine image upload and auth logic for better robustness and developer experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: cb061c75-702e-4b89-a8d1-77a96cdcdfbb
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7cdf4e95-3f41-4180-b8e3-8ef56d032c0e/cb061c75-702e-4b89-a8d1-77a96cdcdfbb/ANdRXVZ
2025-10-07 14:42:22 +00:00

524 lines
18 KiB
TypeScript

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 { supabase } from '@/integrations/supabase/client';
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<UploadedImage[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const objectUrlsRef = useRef<Set<string>>(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 (error) {
console.error('Error revoking object URL:', error);
}
});
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 (error) {
console.error('Error revoking object URL:', error);
}
}
};
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<UploadedImage> => {
try {
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
body: {
metadata: {
filename: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date().toISOString()
},
variant: isAvatar ? 'avatar' : 'public'
}
});
if (uploadError) {
console.error('Upload URL error:', 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');
}
const maxAttempts = 60;
let attempts = 0;
while (attempts < maxAttempts) {
try {
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://ydvtmnrszybqnbcqbdcy.supabase.co';
const response = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${encodeURIComponent(id)}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token}`,
'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 (error) {
console.error('Status poll error:', error);
}
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
revokeObjectUrl(previewUrl);
throw new Error('Upload timeout - image processing took too long');
} catch (error) {
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 supabase.functions.invoke('upload-image', {
method: 'DELETE',
body: { imageId: currentImageId }
});
} catch (deleteError) {
console.warn('Failed to delete old avatar:', deleteError);
}
}
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: any) {
previewUrls.forEach(url => revokeObjectUrl(url));
const errorMessage = error.message || 'Upload failed';
setError(errorMessage);
onError?.(errorMessage);
} 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<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFiles(files);
}
};
const triggerFileSelect = () => {
fileInputRef.current?.click();
};
if (isAvatar) {
return (
<div className={cn('space-y-4', className)}>
<div className="flex items-center gap-4">
<div className="relative">
{uploadedImages.length > 0 ? (
<img
src={uploadedImages[0].thumbnailUrl}
alt="Avatar"
className="w-24 h-24 rounded-full object-cover border-2 border-border"
onError={(e) => {
console.error('Failed to load avatar image:', uploadedImages[0].thumbnailUrl);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
/>
) : existingPhotos.length > 0 ? (
<img
src={existingPhotos[0]}
alt="Avatar"
className="w-24 h-24 rounded-full object-cover border-2 border-border"
/>
) : (
<div className="w-24 h-24 rounded-full bg-muted border-2 border-border flex items-center justify-center">
<Camera className="w-8 h-8 text-muted-foreground" />
</div>
)}
{uploading && (
<div className="absolute inset-0 bg-background/80 rounded-full flex items-center justify-center">
<Progress value={uploadProgress} className="w-16" />
</div>
)}
</div>
<div className="space-y-2">
<Button
onClick={triggerFileSelect}
variant="outline"
size="sm"
disabled={uploading}
>
<Upload className="w-4 h-4 mr-2" />
{uploadedImages.length > 0 || existingPhotos.length > 0 ? 'Change Avatar' : 'Upload Avatar'}
</Button>
<p className="text-xs text-muted-foreground">
JPEG, PNG, WebP up to {maxSizeMB}MB
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
className="hidden"
/>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Upload Area */}
<Card
className={cn(
'border-2 border-dashed transition-colors cursor-pointer',
dragOver && 'border-primary bg-primary/5',
!(isAvatar || canUploadMore) && 'opacity-50 cursor-not-allowed',
isCompact && 'p-2'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={isAvatar || (canUploadMore && !uploading) ? triggerFileSelect : undefined}
>
<CardContent className={cn('p-8 text-center', isCompact && 'p-4')}>
{uploading ? (
<div className="space-y-4">
<div className="animate-pulse">
<Upload className="w-8 h-8 text-primary mx-auto" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Uploading...</p>
<Progress value={uploadProgress} className="w-full" />
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center">
{dragOver ? (
<FileImage className="w-12 h-12 text-primary" />
) : (
<ImageIcon className="w-12 h-12 text-muted-foreground" />
)}
</div>
<div className="space-y-2">
<p className="text-lg font-medium">
{isAvatar ? 'Upload Avatar' : canUploadMore ? 'Upload Photos' : 'Maximum Photos Reached'}
</p>
<p className="text-sm text-muted-foreground">
{isAvatar ? (
<>Drag & drop or click to browse<br />JPEG, PNG, WebP up to {maxSizeMB}MB</>
) : canUploadMore ? (
<>Drag & drop or click to browse<br />JPEG, PNG, WebP up to {maxSizeMB}MB each</>
) : (
`Maximum ${actualMaxFiles} ${actualMaxFiles === 1 ? 'photo' : 'photos'} allowed`
)}
</p>
</div>
{!isAvatar && canUploadMore && (
<Badge variant="outline">
{totalImages}/{actualMaxFiles} {actualMaxFiles === 1 ? 'photo' : 'photos'}
</Badge>
)}
</div>
)}
</CardContent>
</Card>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={maxFiles > 1}
onChange={handleFileSelect}
className="hidden"
disabled={!(isAvatar || canUploadMore) || uploading}
/>
{/* Error Display */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Uploaded Images Preview */}
{uploadedImages.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Uploaded Photos</h4>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{uploadedImages.map((image) => (
<div key={image.id} className="relative group">
<img
src={image.thumbnailUrl}
alt={image.filename}
className="w-full aspect-square object-cover rounded-lg border"
onError={(e) => {
console.error('Failed to load image:', image.thumbnailUrl);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
/>
<Button
variant="destructive"
size="icon"
className="absolute -top-2 -right-2 w-6 h-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeImage(image.id)}
>
<X className="w-3 h-3" />
</Button>
<div className="absolute bottom-2 left-2 right-2">
<Badge variant="secondary" className="text-xs truncate">
{image.filename}
</Badge>
</div>
</div>
))}
</div>
</div>
)}
{/* Existing Photos Display */}
{existingPhotos.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Existing Photos</h4>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{existingPhotos.map((url, index) => (
<div key={index} className="relative">
<img
src={url}
alt={`Existing photo ${index + 1}`}
className="w-full aspect-square object-cover rounded-lg border"
onError={(e) => {
console.error('Failed to load existing image:', url);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
/>
<Badge variant="outline" className="absolute bottom-2 left-2 text-xs">
Existing
</Badge>
</div>
))}
</div>
</div>
)}
</div>
);
}