Files
thrilltrack-explorer/src-old/components/upload/UppyPhotoUpload.tsx

424 lines
12 KiB
TypeScript

import React, { useRef, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { handleError, getErrorMessage } from '@/lib/errorHandler';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Upload, X, Eye, Loader2, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { DragDropZone } from './DragDropZone';
import { Progress } from '@/components/ui/progress';
interface UppyPhotoUploadProps {
onUploadComplete?: (urls: string[]) => void;
onFilesSelected?: (files: File[]) => void;
onUploadStart?: () => void;
onUploadError?: (error: Error) => void;
maxFiles?: number;
maxSizeMB?: number;
allowedFileTypes?: string[];
metadata?: Record<string, any>;
variant?: string;
className?: string;
children?: React.ReactNode;
disabled?: boolean;
showPreview?: boolean;
size?: 'default' | 'compact' | 'large';
enableDragDrop?: boolean;
showUploadModal?: boolean;
deferUpload?: boolean; // If true, don't upload immediately
}
interface CloudflareResponse {
uploadURL: string;
id: string;
}
interface UploadSuccessResponse {
success: boolean;
id: string;
uploaded: boolean;
urls?: {
public: string;
thumbnail: string;
medium: string;
large: string;
avatar: string;
};
}
export function UppyPhotoUpload({
onUploadComplete,
onFilesSelected,
onUploadStart,
onUploadError,
maxFiles = 5,
maxSizeMB = 10,
allowedFileTypes = ['image/*'],
metadata = {},
variant = 'public',
className = '',
children,
disabled = false,
showPreview = true,
size = 'default',
enableDragDrop = true,
deferUpload = false,
}: UppyPhotoUploadProps) {
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [currentFileName, setCurrentFileName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const validateFile = (file: File): string | null => {
const maxSize = maxSizeMB * 1024 * 1024;
if (file.size > maxSize) {
return `File "${file.name}" exceeds ${maxSizeMB}MB limit`;
}
// Check if file type is allowed
// Support both wildcard (image/*) and specific types (image/jpeg, image/png)
const isWildcardMatch = allowedFileTypes.some(type => {
if (type.includes('*')) {
const prefix = type.split('/')[0];
return file.type.startsWith(prefix + '/');
}
return false;
});
const isExactMatch = allowedFileTypes.includes(file.type);
if (!isWildcardMatch && !isExactMatch) {
return `File type "${file.type}" is not allowed`;
}
return null;
};
const uploadSingleFile = async (file: File): Promise<string> => {
setCurrentFileName(file.name);
// Step 1: Get upload URL from Supabase edge function
const urlResponse = await invokeWithTracking('upload-image', { metadata, variant }, undefined);
if (urlResponse.error) {
throw new Error(`Failed to get upload URL: ${urlResponse.error.message}`);
}
const { uploadURL, id: cloudflareId }: CloudflareResponse = urlResponse.data;
// Step 2: Upload file directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadURL, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error(`Cloudflare upload failed: ${uploadResponse.statusText}`);
}
// Step 3: Poll for processing completion
let attempts = 0;
const maxAttempts = 30;
while (attempts < maxAttempts) {
const { data: { session } } = await supabase.auth.getSession();
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://api.thrillwiki.com';
const statusResponse = await fetch(
`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`,
{
headers: {
'Authorization': `Bearer ${session?.access_token || ''}`,
'apikey': import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4',
}
}
);
if (statusResponse.ok) {
const status: UploadSuccessResponse = await statusResponse.json();
if (status.uploaded && status.urls) {
return `https://cdn.thrillwiki.com/images/${cloudflareId}/public`;
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
}
throw new Error('Upload processing timeout');
};
const handleFiles = async (files: File[]) => {
if (disabled || isUploading) return;
// Validate file count
const remainingSlots = maxFiles - uploadedImages.length;
if (files.length > remainingSlots) {
toast({
variant: 'destructive',
title: 'Too Many Files',
description: `You can only upload ${remainingSlots} more file(s). Maximum is ${maxFiles}.`,
});
return;
}
// Validate each file
for (const file of files) {
const error = validateFile(file);
if (error) {
toast({
variant: 'destructive',
title: 'Invalid File',
description: error,
});
return;
}
}
// If deferUpload is true, just notify and don't upload
if (deferUpload) {
onFilesSelected?.(files);
return;
}
// Otherwise, upload immediately (old behavior)
setIsUploading(true);
setUploadProgress(0);
onUploadStart?.();
const newUrls: string[] = [];
const totalFiles = files.length;
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const url = await uploadSingleFile(file);
newUrls.push(url);
setUploadProgress(Math.round(((i + 1) / totalFiles) * 100));
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
handleError(error, {
action: 'Upload Photo File',
metadata: { fileName: file.name, fileSize: file.size, fileType: file.type }
});
toast({
variant: 'destructive',
title: 'Upload Failed',
description: `Failed to upload "${file.name}": ${errorMsg}`,
});
onUploadError?.(error as Error);
}
}
if (newUrls.length > 0) {
const allUrls = [...uploadedImages, ...newUrls];
setUploadedImages(allUrls);
onUploadComplete?.(allUrls);
toast({
title: 'Upload Complete',
description: `Successfully uploaded ${newUrls.length} image(s)`,
});
}
setIsUploading(false);
setUploadProgress(0);
setCurrentFileName('');
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
handleFiles(files);
}
// Reset input
e.target.value = '';
};
const triggerFileSelect = () => {
if (!disabled && !isUploading) {
fileInputRef.current?.click();
}
};
const removeImage = (index: number) => {
const newUrls = uploadedImages.filter((_, i) => i !== index);
setUploadedImages(newUrls);
onUploadComplete?.(newUrls);
};
const getSizeClasses = () => {
switch (size) {
case 'compact':
return 'px-4 py-2 text-sm';
case 'large':
return 'px-8 py-4 text-lg';
default:
return 'px-6 py-3';
}
};
const renderUploadTrigger = () => {
if (children) {
return (
<div onClick={triggerFileSelect} className="cursor-pointer">
{children}
</div>
);
}
const baseClasses = "photo-upload-trigger transition-all duration-300 flex items-center justify-center gap-2";
const sizeClasses = getSizeClasses();
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:scale-105';
return (
<Button
onClick={triggerFileSelect}
disabled={disabled || isUploading}
className={cn(baseClasses, sizeClasses, disabledClasses)}
size={size === 'compact' ? 'sm' : size === 'large' ? 'lg' : 'default'}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading... {uploadProgress}%
</>
) : (
<>
<Upload className="w-4 h-4" />
{size === 'compact' ? 'Upload' : 'Upload Photos'}
</>
)}
</Button>
);
};
const renderPreview = () => {
if (!showPreview || uploadedImages.length === 0) return null;
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-muted-foreground">
Uploaded Photos ({uploadedImages.length})
</span>
<Badge variant="secondary" className="text-xs">
{uploadedImages.length}/{maxFiles}
</Badge>
</div>
<div className="upload-preview-grid">
{uploadedImages.map((url, index) => (
<div key={index} className="upload-preview-item group">
<img
src={url}
alt={`Uploaded ${index + 1}`}
className="w-full h-full object-cover"
/>
<div className="upload-preview-overlay">
<button
onClick={() => removeImage(index)}
className="p-1 bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors mr-2"
title="Remove image"
>
<X className="w-3 h-3" />
</button>
<button
onClick={() => window.open(url, '_blank')}
className="p-1 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
title="View full size"
>
<Eye className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
</div>
);
};
const renderLoadingState = () => {
if (!isUploading) return null;
return (
<div className="mt-4 p-4 bg-muted/50 rounded-lg border border-border space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm font-medium">Uploading...</span>
</div>
<Badge variant="secondary" className="text-xs">
{uploadProgress}%
</Badge>
</div>
{currentFileName && (
<p className="text-xs text-muted-foreground truncate">
{currentFileName}
</p>
)}
<Progress value={uploadProgress} className="h-2" />
</div>
);
};
const renderContent = () => {
if (enableDragDrop && !children) {
return (
<div className="space-y-4">
<DragDropZone
onFilesAdded={handleFiles}
maxFiles={maxFiles}
maxSizeMB={maxSizeMB}
allowedFileTypes={allowedFileTypes}
disabled={disabled || isUploading}
className="min-h-[200px]"
/>
{renderLoadingState()}
{renderPreview()}
</div>
);
}
return (
<div className="space-y-4">
{enableDragDrop && children ? (
<DragDropZone
onFilesAdded={handleFiles}
maxFiles={maxFiles}
maxSizeMB={maxSizeMB}
allowedFileTypes={allowedFileTypes}
disabled={disabled || isUploading}
>
{renderUploadTrigger()}
</DragDropZone>
) : (
renderUploadTrigger()
)}
{renderLoadingState()}
{renderPreview()}
</div>
);
};
return (
<div className={cn("space-y-4", className)}>
<input
ref={fileInputRef}
type="file"
accept={allowedFileTypes.join(',')}
multiple={maxFiles > 1}
onChange={handleFileSelect}
className="hidden"
/>
{renderContent()}
</div>
);
}