Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,199 @@
import React, { useCallback, useState } from 'react';
import { Upload, Image, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/use-toast';
interface DragDropZoneProps {
onFilesAdded: (files: File[]) => void;
maxFiles?: number;
maxSizeMB?: number;
allowedFileTypes?: string[];
disabled?: boolean;
className?: string;
children?: React.ReactNode;
}
export function DragDropZone({
onFilesAdded,
maxFiles = 10,
maxSizeMB = 25,
allowedFileTypes = ['image/*'],
disabled = false,
className = '',
children,
}: DragDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const { toast } = useToast();
const validateFiles = useCallback((files: FileList) => {
const validFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
// Check file type
const isValidType = allowedFileTypes.some(type => {
if (type === 'image/*') {
return file.type.startsWith('image/');
}
return file.type === type;
});
if (!isValidType) {
errors.push(`${file.name}: Invalid file type`);
return;
}
// Check file size
if (file.size > maxSizeMB * 1024 * 1024) {
errors.push(`${file.name}: File too large (max ${maxSizeMB}MB)`);
return;
}
validFiles.push(file);
});
// Check total file count
if (validFiles.length > maxFiles) {
errors.push(`Too many files. Maximum ${maxFiles} files allowed.`);
return { validFiles: validFiles.slice(0, maxFiles), errors };
}
return { validFiles, errors };
}, [allowedFileTypes, maxSizeMB, maxFiles]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (disabled) return;
const files = e.dataTransfer.files;
if (files.length === 0) return;
const { validFiles, errors } = validateFiles(files);
if (errors.length > 0) {
toast({
variant: 'destructive',
title: 'File Validation Error',
description: errors.join(', '),
});
}
if (validFiles.length > 0) {
onFilesAdded(validFiles);
}
}, [disabled, validateFiles, onFilesAdded, toast]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = e.target.files;
if (!files || files.length === 0) return;
const { validFiles, errors } = validateFiles(files);
if (errors.length > 0) {
toast({
variant: 'destructive',
title: 'File Validation Error',
description: errors.join(', '),
});
}
if (validFiles.length > 0) {
onFilesAdded(validFiles);
}
// Reset input
e.target.value = '';
}, [disabled, validateFiles, onFilesAdded, toast]);
if (children) {
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"relative transition-all duration-200",
isDragOver && !disabled && "ring-2 ring-accent ring-offset-2",
className
)}
>
<input
type="file"
multiple
accept={allowedFileTypes.join(',')}
onChange={handleFileInput}
disabled={disabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
{children}
</div>
);
}
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"relative border-2 border-dashed rounded-lg transition-all duration-200 p-8",
isDragOver && !disabled
? "border-accent bg-accent/5 scale-[1.02]"
: "border-border hover:border-accent/50",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "cursor-pointer hover:bg-muted/50",
className
)}
>
<input
type="file"
multiple
accept={allowedFileTypes.join(',')}
onChange={handleFileInput}
disabled={disabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<div className="text-center space-y-4">
<div className="mx-auto w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center">
{isDragOver ? (
<Upload className="w-8 h-8 text-accent animate-bounce" />
) : (
<Image className="w-8 h-8 text-accent" />
)}
</div>
<div className="space-y-2">
<p className="text-lg font-medium">
{isDragOver ? 'Drop files here' : 'Drag & drop photos here'}
</p>
<p className="text-sm text-muted-foreground">
or click to browse files
</p>
<p className="text-xs text-muted-foreground">
Max {maxFiles} files, {maxSizeMB}MB each
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Image as ImageIcon, ImagePlus, X } from 'lucide-react';
import { UppyPhotoUploadLazy } from './UppyPhotoUploadLazy';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
export type ImageType = 'logo' | 'banner' | 'card';
interface ImageSlot {
type: ImageType;
url?: string;
id?: string;
}
interface EntityImageUploaderProps {
images: {
logo?: { url?: string; id?: string };
banner?: { url?: string; id?: string };
card?: { url?: string; id?: string };
};
onImagesChange: (images: {
logo_url?: string;
banner_image_id?: string;
banner_image_url?: string;
card_image_id?: string;
card_image_url?: string;
}) => void;
showLogo?: boolean;
entityType?: string;
}
const IMAGE_SPECS = {
logo: { label: 'Logo', aspect: '1:1', dimensions: '400x400', description: 'Square logo image' },
banner: { label: 'Banner', aspect: '21:9', dimensions: '1920x820', description: 'Wide header image' },
card: { label: 'Card', aspect: '16:9', dimensions: '1200x675', description: 'Preview thumbnail' }
};
export function EntityImageUploader({
images,
onImagesChange,
showLogo = true,
entityType = 'entity'
}: EntityImageUploaderProps) {
const [activeSlot, setActiveSlot] = useState<ImageType | null>(null);
const handleUploadComplete = (type: ImageType, urls: string[]) => {
if (urls.length === 0) return;
const url = urls[0];
const id = url.split('/').pop()?.split('?')[0] || '';
const updates: any = {};
if (type === 'logo') {
updates.logo_url = url;
} else if (type === 'banner') {
updates.banner_image_id = id;
updates.banner_image_url = url;
} else if (type === 'card') {
updates.card_image_id = id;
updates.card_image_url = url;
}
onImagesChange({
logo_url: type === 'logo' ? url : images.logo?.url,
banner_image_id: type === 'banner' ? id : images.banner?.id,
banner_image_url: type === 'banner' ? url : images.banner?.url,
card_image_id: type === 'card' ? id : images.card?.id,
card_image_url: type === 'card' ? url : images.card?.url
});
setActiveSlot(null);
};
const handleRemoveImage = (type: ImageType) => {
const updates: any = {
logo_url: images.logo?.url,
banner_image_id: images.banner?.id,
banner_image_url: images.banner?.url,
card_image_id: images.card?.id,
card_image_url: images.card?.url
};
if (type === 'logo') {
updates.logo_url = undefined;
} else if (type === 'banner') {
updates.banner_image_id = undefined;
updates.banner_image_url = undefined;
} else if (type === 'card') {
updates.card_image_id = undefined;
updates.card_image_url = undefined;
}
onImagesChange(updates);
};
const renderImageSlot = (type: ImageType) => {
if (type === 'logo' && !showLogo) return null;
const spec = IMAGE_SPECS[type];
const currentImage = type === 'logo' ? images.logo : type === 'banner' ? images.banner : images.card;
const hasImage = currentImage?.url;
if (activeSlot === type) {
return (
<Card key={type} className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-base font-semibold">{spec.label}</Label>
<Badge variant="outline" className="text-xs">
{spec.aspect} {spec.dimensions}
</Badge>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setActiveSlot(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">{spec.description}</p>
<UppyPhotoUploadLazy
onUploadComplete={(urls) => handleUploadComplete(type, urls)}
maxFiles={1}
variant="compact"
allowedFileTypes={['image/jpeg', 'image/jpg', 'image/png', 'image/webp']}
/>
</div>
</Card>
);
}
return (
<Card key={type} className="overflow-hidden">
{hasImage ? (
<div className="relative aspect-[16/9] bg-muted">
<img
src={
currentImage.url || (
type === 'logo'
? getCloudflareImageUrl(currentImage.id, 'logo')
: type === 'banner'
? getCloudflareImageUrl(currentImage.id, 'banner')
: getCloudflareImageUrl(currentImage.id, 'card')
)
}
alt={`${spec.label} preview`}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => setActiveSlot(type)}
>
<ImagePlus className="w-4 h-4 mr-2" />
Replace
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => handleRemoveImage(type)}
>
<X className="w-4 h-4 mr-2" />
Remove
</Button>
</div>
<Badge className="absolute top-2 left-2">{spec.label}</Badge>
</div>
) : (
<button
type="button"
onClick={() => setActiveSlot(type)}
className="w-full aspect-[16/9] flex flex-col items-center justify-center gap-2 bg-muted hover:bg-muted/80 transition-colors"
>
<ImageIcon className="w-8 h-8 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{spec.label}</p>
<p className="text-xs text-muted-foreground">{spec.dimensions}</p>
</div>
</button>
)}
</Card>
);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Label className="text-base">Images</Label>
<Badge variant="secondary" className="text-xs">
Recommended formats: JPG, PNG, WebP
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{showLogo && renderImageSlot('logo')}
{renderImageSlot('banner')}
{renderImageSlot('card')}
</div>
</div>
);
}

View File

@@ -0,0 +1,425 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Upload, Star, CreditCard, Trash2, ImagePlus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { DragDropZone } from './DragDropZone';
import { supabase } from '@/lib/supabaseClient';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { getErrorMessage } from '@/lib/errorHandler';
export interface UploadedImage {
url: string;
cloudflare_id?: string;
file?: File;
isLocal?: boolean;
caption?: string;
}
export interface ImageAssignments {
uploaded: UploadedImage[];
banner_assignment?: number | null;
card_assignment?: number | null;
}
interface EntityMultiImageUploaderProps {
mode: 'create' | 'edit';
value: ImageAssignments;
onChange: (assignments: ImageAssignments) => void;
entityId?: string;
entityType?: string;
currentBannerUrl?: string;
currentCardUrl?: string;
}
export function EntityMultiImageUploader({
mode,
value,
onChange,
entityType = 'entity',
entityId,
currentBannerUrl,
currentCardUrl
}: EntityMultiImageUploaderProps) {
const maxImages = mode === 'create' ? 5 : 0; // No uploads allowed in edit mode
const canUpload = mode === 'create';
const [loadingPhotos, setLoadingPhotos] = useState(false);
// Fetch existing photos when in edit mode
useEffect(() => {
if (mode === 'edit' && entityId && entityType) {
fetchEntityPhotos();
}
}, [mode, entityId, entityType]);
// Cleanup blob URLs when component unmounts or images change
useEffect(() => {
const currentImages = value.uploaded;
return () => {
// Revoke all blob URLs on cleanup
currentImages.forEach(image => {
if (image.isLocal && image.url.startsWith('blob:')) {
try {
URL.revokeObjectURL(image.url);
} catch {
// Silent cleanup failure - non-critical
}
}
});
};
}, [value.uploaded]);
const fetchEntityPhotos = async () => {
setLoadingPhotos(true);
try {
const { data, error } = await supabase
.from('photos')
.select('id, cloudflare_image_url, cloudflare_image_id, caption, title')
.eq('entity_type', entityType)
.eq('entity_id', entityId || '')
.order('created_at', { ascending: false });
if (error) throw error;
// Map to UploadedImage format
const mappedPhotos: UploadedImage[] = data?.map(photo => ({
url: photo.cloudflare_image_url,
cloudflare_id: photo.cloudflare_image_id,
caption: photo.caption || photo.title || '',
isLocal: false,
})) || [];
// Find banner and card indices based on currentBannerUrl/currentCardUrl
const bannerIndex = mappedPhotos.findIndex(p => p.url === currentBannerUrl);
const cardIndex = mappedPhotos.findIndex(p => p.url === currentCardUrl);
// Initialize with existing photos and current assignments
onChange({
uploaded: mappedPhotos,
banner_assignment: bannerIndex >= 0 ? bannerIndex : null,
card_assignment: cardIndex >= 0 ? cardIndex : null,
});
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
} finally {
setLoadingPhotos(false);
}
};
const handleFilesAdded = (files: File[]) => {
// Block uploads entirely in edit mode
if (!canUpload) {
toast({
title: 'Upload Not Allowed',
description: 'Photos cannot be added during edits. Use the photo gallery to submit additional photos.',
variant: 'destructive',
});
return;
}
const currentCount = value.uploaded.length;
const availableSlots = maxImages - currentCount;
if (availableSlots <= 0) {
return;
}
const filesToAdd = files.slice(0, availableSlots);
const newImages: UploadedImage[] = filesToAdd.map(file => ({
url: URL.createObjectURL(file),
file,
isLocal: true,
}));
const updatedUploaded = [...value.uploaded, ...newImages];
const updatedValue: ImageAssignments = {
uploaded: updatedUploaded,
banner_assignment: value.banner_assignment,
card_assignment: value.card_assignment,
};
// Auto-assign banner if not set
if (updatedValue.banner_assignment === undefined || updatedValue.banner_assignment === null) {
if (updatedUploaded.length > 0) {
updatedValue.banner_assignment = 0;
}
}
// Auto-assign card if not set
if (updatedValue.card_assignment === undefined || updatedValue.card_assignment === null) {
if (updatedUploaded.length > 0) {
updatedValue.card_assignment = 0;
}
}
onChange(updatedValue);
};
const handleAssignRole = (index: number, role: 'banner' | 'card') => {
const updatedValue: ImageAssignments = {
...value,
[role === 'banner' ? 'banner_assignment' : 'card_assignment']: index,
};
onChange(updatedValue);
};
const handleRemoveImage = (index: number) => {
const imageToRemove = value.uploaded[index];
// Revoke object URL if it's a local file
if (imageToRemove.isLocal && imageToRemove.url.startsWith('blob:')) {
URL.revokeObjectURL(imageToRemove.url);
}
const updatedUploaded = value.uploaded.filter((_, i) => i !== index);
const updatedValue: ImageAssignments = {
uploaded: updatedUploaded,
banner_assignment: value.banner_assignment === index
? (updatedUploaded.length > 0 ? 0 : null)
: value.banner_assignment !== null && value.banner_assignment !== undefined && value.banner_assignment > index
? value.banner_assignment - 1
: value.banner_assignment,
card_assignment: value.card_assignment === index
? (updatedUploaded.length > 0 ? 0 : null)
: value.card_assignment !== null && value.card_assignment !== undefined && value.card_assignment > index
? value.card_assignment - 1
: value.card_assignment,
};
onChange(updatedValue);
};
const renderImageCard = (image: UploadedImage, index: number) => {
const isBanner = value.banner_assignment === index;
const isCard = value.card_assignment === index;
const isExisting = !image.isLocal;
return (
<ContextMenu key={index}>
<ContextMenuTrigger>
<div className="relative group cursor-context-menu">
<div className={`aspect-[4/3] rounded-lg overflow-hidden border-2 ${
isExisting ? 'border-blue-400' : 'border-border'
} bg-muted`}>
<img
src={image.url}
alt={`Upload ${index + 1}`}
className="w-full h-full object-cover"
/>
</div>
{/* Role badges - always visible */}
<div className="absolute top-2 left-2 flex gap-1 flex-wrap">
{isBanner && (
<Badge variant="default" className="gap-1">
<Star className="h-3 w-3" />
Banner
</Badge>
)}
{isCard && (
<Badge variant="secondary" className="gap-1">
<CreditCard className="h-3 w-3" />
Card
</Badge>
)}
{isExisting && (
<Badge variant="outline" className="bg-blue-100 dark:bg-blue-900">
Existing
</Badge>
)}
{image.isLocal && (
<Badge variant="outline" className="bg-background/80">
Not uploaded
</Badge>
)}
</div>
{/* Remove button - only for new uploads */}
{image.isLocal && (
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage(index);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
)}
{/* Hover hint */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors rounded-lg flex items-center justify-center">
<p className="text-background text-xs opacity-0 group-hover:opacity-100 transition-opacity font-medium">
Right-click for options
</p>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => handleAssignRole(index, 'banner')}
disabled={isBanner}
>
<Star className="mr-2 h-4 w-4" />
{isBanner ? 'Banner (Current)' : 'Set as Banner'}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleAssignRole(index, 'card')}
disabled={isCard}
>
<CreditCard className="mr-2 h-4 w-4" />
{isCard ? 'Card (Current)' : 'Set as Card'}
</ContextMenuItem>
{image.isLocal && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => handleRemoveImage(index)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Remove Image
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
};
const getHelperText = () => {
if (value.uploaded.length === 0) {
return 'Upload images to get started. Images will be uploaded when you submit the form.';
}
const existingCount = value.uploaded.filter(img => !img.isLocal).length;
const newCount = value.uploaded.filter(img => img.isLocal).length;
const parts: string[] = [];
if (mode === 'edit' && existingCount > 0) {
parts.push(`${existingCount} existing photo${existingCount !== 1 ? 's' : ''}`);
}
if (newCount > 0) {
parts.push(`${newCount} new image${newCount > 1 ? 's' : ''} ready to upload`);
}
parts.push('Right-click to assign banner/card roles');
return parts.join(' • ');
};
// Loading state
if (loadingPhotos) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="aspect-[4/3] rounded-lg" />
))}
</div>
<p className="text-sm text-muted-foreground">Loading existing photos...</p>
</div>
);
}
// Empty state: show large drag & drop zone (create only) or message (edit)
if (value.uploaded.length === 0) {
if (mode === 'edit') {
return (
<Alert>
<AlertDescription>
No existing photos found. Photos can only be added during entity creation. Use the photo gallery to submit additional photos after creation.
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
<DragDropZone
onFilesAdded={handleFilesAdded}
maxFiles={maxImages}
maxSizeMB={25}
allowedFileTypes={['image/*']}
/>
<div className="text-sm text-muted-foreground space-y-1">
<p> Right-click images to set as banner or card</p>
<p> Images will be uploaded when you submit the form</p>
</div>
</div>
);
}
// With images: show grid + compact upload area (create only) or read-only (edit)
return (
<div className="space-y-4">
{/* Edit mode notice */}
{mode === 'edit' && (
<Alert>
<AlertDescription>
Photos cannot be added during edits. You can reassign banner/card roles below. Use the photo gallery to submit additional photos.
</AlertDescription>
</Alert>
)}
{/* Image Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{value.uploaded.map((image, index) => renderImageCard(image, index))}
</div>
{/* Compact Upload Area - Create mode only */}
{canUpload && value.uploaded.length < maxImages && (
<DragDropZone
onFilesAdded={handleFilesAdded}
maxFiles={maxImages - value.uploaded.length}
maxSizeMB={25}
allowedFileTypes={['image/*']}
className="p-6"
>
<div className="text-center space-y-2">
<ImagePlus className="w-8 h-8 mx-auto text-muted-foreground" />
<p className="text-sm font-medium">Add More Images</p>
<p className="text-xs text-muted-foreground">
Drag & drop or click to browse ({value.uploaded.length}/{maxImages})
</p>
</div>
</DragDropZone>
)}
{/* Helper Text */}
<div className="space-y-2 text-sm text-muted-foreground">
<p className="font-medium">{getHelperText()}</p>
<div className="space-y-1 pl-4 border-l-2 border-border">
<p>
<strong>Banner:</strong> Main header image for the {entityType} detail page
{value.banner_assignment !== null && value.banner_assignment !== undefined && ` (Image ${value.banner_assignment + 1})`}
</p>
<p>
<strong>Card:</strong> Thumbnail in {entityType} listings and search results
{value.card_assignment !== null && value.card_assignment !== undefined && ` (Image ${value.card_assignment + 1})`}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
import { useState, useEffect } from 'react';
import { Camera, Upload, LogIn, Settings, ArrowUpDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAuth } from '@/hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { UppyPhotoSubmissionUpload } from '@/components/upload/UppyPhotoSubmissionUpload';
import { PhotoManagementDialog } from '@/components/upload/PhotoManagementDialog';
import { PhotoModal } from '@/components/moderation/PhotoModal';
import { supabase } from '@/lib/supabaseClient';
import { EntityPhotoGalleryProps } from '@/types/submissions';
import { useUserRole } from '@/hooks/useUserRole';
import { getErrorMessage } from '@/lib/errorHandler';
interface Photo {
id: string;
url: string;
caption?: string;
title?: string;
user_id: string;
created_at: string;
}
export function EntityPhotoGallery({
entityId,
entityType,
entityName,
parentId
}: EntityPhotoGalleryProps) {
const { user } = useAuth();
const navigate = useNavigate();
const { isModerator } = useUserRole();
const [photos, setPhotos] = useState<Photo[]>([]);
const [showUpload, setShowUpload] = useState(false);
const [showManagement, setShowManagement] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [sortBy, setSortBy] = useState<'newest' | 'oldest'>('newest');
useEffect(() => {
fetchPhotos();
}, [entityId, entityType, sortBy]);
const fetchPhotos = async () => {
try {
// Fetch photos directly from the photos table
const { data: photoData, error } = await supabase
.from('photos')
.select('id, cloudflare_image_url, title, caption, submitted_by, created_at, order_index')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('created_at', { ascending: sortBy === 'oldest' });
if (error) throw error;
// Map to Photo interface
const mappedPhotos: Photo[] = photoData?.map((photo) => ({
id: photo.id,
url: photo.cloudflare_image_url,
caption: photo.caption || undefined,
title: photo.title || undefined,
user_id: photo.submitted_by || '',
created_at: photo.created_at,
})) || [];
setPhotos(mappedPhotos);
} catch (error: unknown) {
// Photo fetch failed - display empty gallery
} finally {
setLoading(false);
}
};
const handleUploadClick = () => {
if (!user) {
navigate('/auth');
return;
}
setShowUpload(true);
};
const handleSubmissionComplete = () => {
setShowUpload(false);
fetchPhotos(); // Refresh photos after submission
};
const handlePhotoClick = (index: number) => {
setSelectedPhotoIndex(index);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSelectedPhotoIndex(null);
};
if (showUpload) {
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-0">
<div>
<h3 className="text-base sm:text-lg font-semibold">Submit Additional Photos for {entityName}</h3>
<p className="text-sm text-muted-foreground">Photos submitted here go through a separate review process</p>
</div>
<Button variant="ghost" onClick={() => setShowUpload(false)} className="w-full sm:w-auto">
Back to Gallery
</Button>
</div>
<UppyPhotoSubmissionUpload
entityId={entityId}
entityType={entityType}
parentId={parentId}
onSubmissionComplete={handleSubmissionComplete}
/>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-8 sm:py-12">
<div className="animate-pulse flex items-center gap-3">
<Camera className="w-6 h-6 sm:w-8 sm:h-8 text-muted-foreground" />
<span className="text-sm sm:text-base text-muted-foreground">Loading photos...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header with Upload and Management Buttons */}
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="text-base sm:text-lg font-semibold">Photo Gallery</h3>
<p className="text-sm text-muted-foreground hidden sm:block">
Share your photos of {entityName}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{isModerator() && photos.length > 0 && (
<Button onClick={() => setShowManagement(true)} variant="outline" className="gap-2 w-full sm:w-auto">
<Settings className="w-4 h-4" />
<span className="sm:inline">Manage</span>
</Button>
)}
<Button onClick={handleUploadClick} className="gap-2 w-full sm:w-auto">
{user ? (
<>
<Upload className="w-4 h-4" />
<span className="sm:inline">Submit Additional Photos</span>
</>
) : (
<>
<LogIn className="w-4 h-4" />
<span className="sm:inline">Sign in to Upload</span>
</>
)}
</Button>
</div>
</div>
{/* Sort Dropdown */}
{photos.length > 0 && (
<div className="flex items-center gap-2">
<ArrowUpDown className="w-4 h-4 text-muted-foreground" />
<Select value={sortBy} onValueChange={(value: 'newest' | 'oldest') => setSortBy(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="oldest">Oldest First</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Photo Management Dialog */}
<PhotoManagementDialog
entityId={entityId}
entityType={entityType}
open={showManagement}
onOpenChange={setShowManagement}
onUpdate={fetchPhotos}
/>
{/* Photo Grid */}
{photos.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">
{photos.map((photo, index) => (
<Card key={photo.id} className="overflow-hidden">
<CardContent className="p-0">
<img
src={photo.url}
alt={photo.title || photo.caption || `${entityName} photo`}
className="w-full h-48 sm:h-56 md:h-48 object-cover transition-transform cursor-pointer touch-manipulation active:opacity-80 sm:hover:scale-105"
onClick={() => handlePhotoClick(index)}
/>
{photo.caption && (
<div className="p-2 sm:p-3">
<p className="text-[10px] sm:text-xs text-muted-foreground line-clamp-2">
{photo.caption}
</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-12 px-4">
<Camera className="w-12 h-12 sm:w-16 sm:h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg sm:text-xl font-semibold mb-2">No Photos Yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Be the first to share photos of {entityName}!
</p>
<Button onClick={handleUploadClick} className="gap-2 w-full sm:w-auto">
{user ? (
<>
<Upload className="w-4 h-4" />
<span>Upload First Photo</span>
</>
) : (
<>
<LogIn className="w-4 h-4" />
<span>Sign in to Upload</span>
</>
)}
</Button>
</div>
)}
{/* Photo Lightbox Modal */}
{selectedPhotoIndex !== null && (
<PhotoModal
photos={photos.map(photo => ({
id: photo.id,
url: photo.url,
caption: photo.caption,
filename: photo.title,
}))}
initialIndex={selectedPhotoIndex}
isOpen={isModalOpen}
onClose={handleCloseModal}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,243 @@
import React, { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { X, Eye, GripVertical, Edit3, CalendarIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
export interface PhotoWithCaption {
url: string; // Object URL for preview, Cloudflare URL after upload
file?: File; // The actual file to upload later
caption: string;
title?: string;
date?: Date; // Optional date for the photo
order: number;
uploadStatus?: 'pending' | 'uploading' | 'uploaded' | 'failed';
cloudflare_id?: string; // Cloudflare Image ID after upload
}
interface PhotoCaptionEditorProps {
photos: PhotoWithCaption[];
onPhotosChange: (photos: PhotoWithCaption[]) => void;
onRemovePhoto: (index: number) => void;
maxCaptionLength?: number;
className?: string;
}
export function PhotoCaptionEditor({
photos,
onPhotosChange,
onRemovePhoto,
maxCaptionLength = 200,
className = '',
}: PhotoCaptionEditorProps) {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const updatePhotoCaption = (index: number, caption: string) => {
const updatedPhotos = photos.map((photo, i) =>
i === index ? { ...photo, caption } : photo
);
onPhotosChange(updatedPhotos);
};
const updatePhotoTitle = (index: number, title: string) => {
const updatedPhotos = photos.map((photo, i) =>
i === index ? { ...photo, title } : photo
);
onPhotosChange(updatedPhotos);
};
const updatePhotoDate = (index: number, date: Date | undefined) => {
const updatedPhotos = photos.map((photo, i) =>
i === index ? { ...photo, date } : photo
);
onPhotosChange(updatedPhotos);
};
const movePhoto = (fromIndex: number, toIndex: number) => {
const updatedPhotos = [...photos];
const [movedPhoto] = updatedPhotos.splice(fromIndex, 1);
updatedPhotos.splice(toIndex, 0, movedPhoto);
// Update order values
const reorderedPhotos = updatedPhotos.map((photo, index) => ({
...photo,
order: index,
}));
onPhotosChange(reorderedPhotos);
};
if (photos.length === 0) {
return null;
}
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center justify-between">
<Label className="text-base font-medium">Photo Captions</Label>
<Badge variant="secondary" className="text-xs">
{photos.length} photo{photos.length !== 1 ? 's' : ''}
</Badge>
</div>
<div className="space-y-3">
{photos.map((photo, index) => (
<Card key={`${photo.url}-${index}`} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex gap-4">
{/* Photo preview */}
<div className="relative flex-shrink-0">
<img
src={photo.url}
alt={photo.title || `Photo ${index + 1}`}
className="w-24 h-24 object-cover rounded-lg"
/>
<div className="absolute -top-2 -right-2 flex gap-1">
<button
onClick={() => onRemovePhoto(index)}
className="p-1 bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors shadow-lg"
title="Remove photo"
>
<X className="w-3 h-3" />
</button>
<button
onClick={() => window.open(photo.url, '_blank')}
className="p-1 bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors shadow-lg"
title="View full size"
>
<Eye className="w-3 h-3" />
</button>
</div>
</div>
{/* Caption editing */}
<div className="flex-1 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Photo {index + 1}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingIndex(editingIndex === index ? null : index)}
className="h-7 px-2"
>
<Edit3 className="w-3 h-3" />
</Button>
<div
className="cursor-grab hover:cursor-grabbing p-1 text-muted-foreground hover:text-foreground transition-colors"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4" />
</div>
</div>
</div>
{editingIndex === index ? (
<div className="space-y-2">
<div>
<Label htmlFor={`title-${index}`} className="text-xs">
Title (optional)
</Label>
<Input
id={`title-${index}`}
value={photo.title || ''}
onChange={(e) => updatePhotoTitle(index, e.target.value)}
placeholder="Photo title"
maxLength={100}
className="h-8 text-sm"
/>
</div>
<div>
<Label htmlFor={`date-${index}`} className="text-xs">
Date (optional)
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id={`date-${index}`}
variant="outline"
className={cn(
"w-full h-8 justify-start text-left font-normal text-sm",
!photo.date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-3 w-3" />
{photo.date ? format(photo.date, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={photo.date}
onSelect={(date) => updatePhotoDate(index, date)}
disabled={(date) => date > new Date()}
initialFocus
className={cn("p-3 pointer-events-auto")}
/>
{photo.date && (
<div className="p-2 border-t">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs"
onClick={() => updatePhotoDate(index, undefined)}
>
Clear date
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
<div>
<Label htmlFor={`caption-${index}`} className="text-xs">
Caption <span className="text-muted-foreground">(optional but recommended)</span>
</Label>
<Input
id={`caption-${index}`}
value={photo.caption}
onChange={(e) => updatePhotoCaption(index, e.target.value)}
placeholder="Add a caption to help viewers understand this photo..."
maxLength={maxCaptionLength}
className="h-8 text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{photo.caption.length}/{maxCaptionLength} characters
</p>
</div>
</div>
) : (
<div className="space-y-1">
{photo.title && (
<p className="text-sm font-medium">{photo.title}</p>
)}
{photo.date && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<CalendarIcon className="w-3 h-3" />
{format(photo.date, "PPP")}
</p>
)}
<p className="text-sm text-muted-foreground">
{photo.caption || (
<span className="italic">No caption added</span>
)}
</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,410 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/hooks/use-toast';
import { Trash2, Pencil } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getErrorMessage } from '@/lib/errorHandler';
interface Photo {
id: string;
cloudflare_image_id: string;
cloudflare_image_url: string;
title: string | null;
caption: string | null;
order_index: number;
is_featured: boolean;
}
interface PhotoManagementDialogProps {
entityId: string;
entityType: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdate?: () => void;
}
export function PhotoManagementDialog({
entityId,
entityType,
open,
onOpenChange,
onUpdate,
}: PhotoManagementDialogProps) {
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [editingPhoto, setEditingPhoto] = useState<Photo | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(null);
const [deleteReason, setDeleteReason] = useState('');
const { toast } = useToast();
useEffect(() => {
if (open) {
fetchPhotos();
}
}, [open, entityId, entityType]);
const fetchPhotos = async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from('photos')
.select('id, cloudflare_image_id, cloudflare_image_url, title, caption, order_index, is_featured')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('order_index', { ascending: true });
if (error) throw error;
setPhotos((data || []).map(p => ({
...p,
order_index: p.order_index ?? 0,
is_featured: p.is_featured ?? false
})) as Photo[]);
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleDeleteClick = (photo: Photo) => {
setPhotoToDelete(photo);
setDeleteReason('');
setDeleteDialogOpen(true);
};
const requestPhotoDelete = async () => {
if (!photoToDelete || !deleteReason.trim()) return;
try {
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Fetch entity name from database based on entity type
let entityName = 'Unknown';
try {
if (entityType === 'park') {
const { data } = await supabase.from('parks').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (entityType === 'ride') {
const { data } = await supabase.from('rides').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (entityType === 'ride_model') {
const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
const { data } = await supabase.from('companies').select('name').eq('id', entityId).single();
if (data?.name) entityName = data.name;
}
} catch {
// Failed to fetch entity name - use default
}
// Create content submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')
.insert([{
user_id: user.id,
submission_type: 'photo_delete',
content: {
action: 'delete',
photo_id: photoToDelete.id
}
}])
.select()
.single();
if (submissionError) throw submissionError;
// Create submission item with all necessary data
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submission.id,
item_type: 'photo_delete',
item_data: {
photo_id: photoToDelete.id,
cloudflare_image_id: photoToDelete.cloudflare_image_id,
entity_type: entityType,
entity_id: entityId,
entity_name: entityName,
cloudflare_image_url: photoToDelete.cloudflare_image_url,
title: photoToDelete.title,
caption: photoToDelete.caption,
deletion_reason: deleteReason
},
status: 'pending'
});
if (itemError) throw itemError;
toast({
title: 'Delete request submitted',
description: 'Your photo deletion request has been submitted for moderation',
});
setDeleteDialogOpen(false);
setPhotoToDelete(null);
setDeleteReason('');
onOpenChange(false);
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
};
const requestPhotoEdit = async () => {
if (!editingPhoto) return;
try {
// Get current user
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Get original photo data
const originalPhoto = photos.find(p => p.id === editingPhoto.id);
if (!originalPhoto) throw new Error('Original photo not found');
// Create content submission
const { data: submission, error: submissionError } = await supabase
.from('content_submissions')
.insert([{
user_id: user.id,
submission_type: 'photo_edit',
content: {
action: 'edit',
photo_id: editingPhoto.id
}
}])
.select()
.single();
if (submissionError) throw submissionError;
// Create submission item
const { error: itemError } = await supabase
.from('submission_items')
.insert({
submission_id: submission.id,
item_type: 'photo_edit',
item_data: {
photo_id: editingPhoto.id,
entity_type: entityType,
entity_id: entityId,
new_caption: editingPhoto.caption,
cloudflare_image_url: editingPhoto.cloudflare_image_url,
},
original_data: {
caption: originalPhoto.caption,
},
status: 'pending'
});
if (itemError) throw itemError;
setEditingPhoto(null);
toast({
title: 'Edit request submitted',
description: 'Your photo edit has been submitted for moderation',
});
onOpenChange(false);
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive',
});
}
};
if (editingPhoto) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Photo</DialogTitle>
<DialogDescription>Update photo caption</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="aspect-video w-full overflow-hidden rounded-lg">
<img
src={editingPhoto.cloudflare_image_url}
alt={editingPhoto.caption || 'Photo'}
className="w-full h-full object-cover"
/>
</div>
<div className="space-y-2">
<Label htmlFor="caption">Caption</Label>
<Textarea
id="caption"
value={editingPhoto.caption || ''}
onChange={(e) =>
setEditingPhoto({ ...editingPhoto, caption: e.target.value })
}
placeholder="Photo caption"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
Cancel
</Button>
<Button onClick={requestPhotoEdit}>Submit for Review</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl lg:max-w-4xl max-h-[80vh] overflow-y-auto p-3 sm:p-6">
<DialogHeader>
<DialogTitle>Manage Photos</DialogTitle>
<DialogDescription>
Edit or delete photos for this entity
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-pulse">Loading photos...</div>
</div>
) : photos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No photos to manage
</div>
) : (
<div className="space-y-4">
{photos.map((photo, index) => (
<Card key={photo.id}>
<CardContent className="p-3 sm:p-4">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="w-full aspect-video sm:w-32 sm:h-32 flex-shrink-0 overflow-hidden rounded-lg">
<img
src={photo.cloudflare_image_url}
alt={photo.caption || 'Photo'}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 space-y-2">
<div>
<p className="text-sm sm:text-base text-foreground">
{photo.caption || (
<span className="text-muted-foreground italic">No caption</span>
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setEditingPhoto(photo)}
className="flex-1 sm:flex-initial"
>
<Pencil className="w-4 h-4 mr-2" />
Request Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick(photo)}
className="flex-1 sm:flex-initial"
>
<Trash2 className="w-4 h-4 mr-2" />
Request Delete
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Request Photo Deletion</AlertDialogTitle>
<AlertDialogDescription>
Please provide a reason for deleting this photo. This request will be reviewed by moderators.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-reason">Reason for deletion</Label>
<Textarea
id="delete-reason"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
placeholder="Please explain why this photo should be deleted..."
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setDeleteDialogOpen(false);
setPhotoToDelete(null);
setDeleteReason('');
}}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={requestPhotoDelete}
disabled={!deleteReason.trim()}
>
Submit Request
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@@ -0,0 +1,535 @@
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<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 {
// 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<UploadedImage> => {
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<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) => {
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) => {
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) => {
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>
);
}

View File

@@ -0,0 +1,713 @@
import React, { useState } from "react";
import { invokeWithTracking } from "@/lib/edgeFunctionTracking";
import { handleError, getErrorMessage } from "@/lib/errorHandler";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { UppyPhotoUploadLazy } from "./UppyPhotoUploadLazy";
import { PhotoCaptionEditor, PhotoWithCaption } from "./PhotoCaptionEditor";
import { supabase } from "@/lib/supabaseClient";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { Camera, CheckCircle, AlertCircle, Info, XCircle } from "lucide-react";
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
import { withRetry, isRetryableError } from "@/lib/retryHelpers";
import { logger } from "@/lib/logger";
import { breadcrumb } from "@/lib/errorBreadcrumbs";
import { checkSubmissionRateLimit, recordSubmissionAttempt } from "@/lib/submissionRateLimiter";
import { sanitizeErrorMessage } from "@/lib/errorSanitizer";
import { reportBanEvasionAttempt } from "@/lib/pipelineAlerts";
/**
* Photo upload pipeline configuration
* Bulletproof retry and recovery settings
*/
const UPLOAD_CONFIG = {
MAX_UPLOAD_ATTEMPTS: 3,
MAX_DB_ATTEMPTS: 3,
POLLING_TIMEOUT_SECONDS: 30,
POLLING_INTERVAL_MS: 1000,
BASE_RETRY_DELAY: 1000,
MAX_RETRY_DELAY: 10000,
ALLOW_PARTIAL_SUCCESS: true, // Allow submission even if some photos fail
} as const;
export function UppyPhotoSubmissionUpload({
onSubmissionComplete,
entityId,
entityType,
parentId,
}: UppyPhotoSubmissionUploadProps) {
const [title, setTitle] = useState("");
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null);
const [failedPhotos, setFailedPhotos] = useState<Array<{ index: number; error: string }>>([]);
const [orphanedCloudflareIds, setOrphanedCloudflareIds] = useState<string[]>([]);
const { user } = useAuth();
const { toast } = useToast();
const handleFilesSelected = (files: File[]) => {
// Convert files to photo objects with object URLs for preview
const newPhotos: PhotoWithCaption[] = files.map((file, index) => ({
url: URL.createObjectURL(file), // Object URL for preview
file, // Store the file for later upload
caption: "",
order: photos.length + index,
uploadStatus: "pending" as const,
}));
setPhotos((prev) => [...prev, ...newPhotos]);
};
const handlePhotosChange = (updatedPhotos: PhotoWithCaption[]) => {
setPhotos(updatedPhotos);
};
const handleRemovePhoto = (index: number) => {
setPhotos((prev) => {
const photo = prev[index];
// Revoke object URL if it exists
if (photo.file && photo.url.startsWith("blob:")) {
URL.revokeObjectURL(photo.url);
}
return prev.filter((_, i) => i !== index);
});
};
const handleSubmit = async () => {
if (!user) {
toast({
variant: "destructive",
title: "Authentication Required",
description: "Please sign in to submit photos.",
});
return;
}
if (photos.length === 0) {
toast({
variant: "destructive",
title: "No Photos",
description: "Please upload at least one photo before submitting.",
});
return;
}
setIsSubmitting(true);
// ✅ Declare uploadedPhotos outside try block for error handling scope
const uploadedPhotos: PhotoWithCaption[] = [];
try {
// ✅ Phase 4: Rate limiting check
const rateLimit = checkSubmissionRateLimit(user.id);
if (!rateLimit.allowed) {
const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded');
logger.warn('[RateLimit] Photo submission blocked', {
userId: user.id,
reason: rateLimit.reason
});
throw new Error(sanitizedMessage);
}
recordSubmissionAttempt(user.id);
// ✅ Phase 4: Breadcrumb tracking
breadcrumb.userAction('Start photo submission', 'handleSubmit', {
photoCount: photos.length,
entityType,
entityId,
userId: user.id
});
// ✅ Phase 4: Ban check with retry
breadcrumb.apiCall('profiles', 'SELECT');
const profile = await withRetry(
async () => {
const { data, error } = await supabase
.from('profiles')
.select('banned')
.eq('user_id', user.id)
.single();
if (error) throw error;
return data;
},
{ maxAttempts: 2 }
);
if (profile?.banned) {
// Report ban evasion attempt
reportBanEvasionAttempt(user.id, 'photo_upload').catch(() => {
// Non-blocking - don't fail if alert fails
});
throw new Error('Account suspended. Contact support for assistance.');
}
// ✅ Phase 4: Validate photos before processing
if (photos.some(p => !p.file)) {
throw new Error('All photos must have valid files');
}
breadcrumb.userAction('Upload images', 'handleSubmit', {
totalImages: photos.length
});
// ✅ Phase 4: Upload all photos with bulletproof error recovery
const photosToUpload = photos.filter((p) => p.file);
const uploadFailures: Array<{ index: number; error: string; photo: PhotoWithCaption }> = [];
if (photosToUpload.length > 0) {
setUploadProgress({ current: 0, total: photosToUpload.length });
setFailedPhotos([]);
for (let i = 0; i < photosToUpload.length; i++) {
const photo = photosToUpload[i];
const photoIndex = photos.indexOf(photo);
setUploadProgress({ current: i + 1, total: photosToUpload.length });
// Update status
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
try {
// ✅ Bulletproof: Explicit retry configuration with exponential backoff
const cloudflareResult = await withRetry(
async () => {
// Get upload URL from edge function
const { data: uploadData, error: uploadError } = await invokeWithTracking(
"upload-image",
{ metadata: { requireSignedURLs: false }, variant: "public" },
user?.id,
);
if (uploadError) throw uploadError;
const { uploadURL, id: cloudflareId } = uploadData;
// Upload file to Cloudflare
if (!photo.file) {
throw new Error("Photo file is missing");
}
const formData = new FormData();
formData.append("file", photo.file);
const uploadResponse = await fetch(uploadURL, {
method: "POST",
body: formData,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text().catch(() => 'Unknown error');
throw new Error(`Cloudflare upload failed: ${errorText}`);
}
// ✅ Bulletproof: Configurable polling with timeout
let attempts = 0;
const maxAttempts = UPLOAD_CONFIG.POLLING_TIMEOUT_SECONDS;
let cloudflareUrl = "";
while (attempts < maxAttempts) {
const {
data: { session },
} = await supabase.auth.getSession();
const supabaseUrl = "https://api.thrillwiki.com";
const statusResponse = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, {
headers: {
Authorization: `Bearer ${session?.access_token || ""}`,
apikey:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4",
},
});
if (statusResponse.ok) {
const status = await statusResponse.json();
if (status.uploaded && status.urls) {
cloudflareUrl = status.urls.public;
break;
}
}
await new Promise((resolve) => setTimeout(resolve, UPLOAD_CONFIG.POLLING_INTERVAL_MS));
attempts++;
}
if (!cloudflareUrl) {
// Track orphaned upload for cleanup
setOrphanedCloudflareIds(prev => [...prev, cloudflareId]);
throw new Error("Upload processing timeout - image may be uploaded but not ready");
}
return { cloudflareUrl, cloudflareId };
},
{
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
baseDelay: UPLOAD_CONFIG.BASE_RETRY_DELAY,
maxDelay: UPLOAD_CONFIG.MAX_RETRY_DELAY,
shouldRetry: (error) => {
// ✅ Bulletproof: Intelligent retry logic
if (error instanceof Error) {
const message = error.message.toLowerCase();
// Don't retry validation errors or file too large
if (message.includes('file is missing')) return false;
if (message.includes('too large')) return false;
if (message.includes('invalid file type')) return false;
}
return isRetryableError(error);
},
onRetry: (attempt, error, delay) => {
logger.warn('Retrying photo upload', {
attempt,
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
delay,
fileName: photo.file?.name,
error: error instanceof Error ? error.message : String(error)
});
// Emit event for UI indicator
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: {
id: crypto.randomUUID(),
attempt,
maxAttempts: UPLOAD_CONFIG.MAX_UPLOAD_ATTEMPTS,
delay,
type: `photo upload: ${photo.file?.name || 'unnamed'}`
}
}));
}
}
);
// Revoke object URL
URL.revokeObjectURL(photo.url);
uploadedPhotos.push({
...photo,
url: cloudflareResult.cloudflareUrl,
cloudflare_id: cloudflareResult.cloudflareId,
uploadStatus: "uploaded" as const,
});
// Update status
setPhotos((prev) =>
prev.map((p) => (p === photo ? {
...p,
url: cloudflareResult.cloudflareUrl,
cloudflare_id: cloudflareResult.cloudflareId,
uploadStatus: "uploaded" as const
} : p)),
);
logger.info('Photo uploaded successfully', {
fileName: photo.file?.name,
cloudflareId: cloudflareResult.cloudflareId,
photoIndex: i + 1,
totalPhotos: photosToUpload.length
});
} catch (error: unknown) {
const errorMsg = sanitizeErrorMessage(error);
logger.error('Photo upload failed after all retries', {
fileName: photo.file?.name,
photoIndex: i + 1,
error: errorMsg,
retriesExhausted: true
});
handleError(error, {
action: 'Upload Photo',
userId: user.id,
metadata: {
photoTitle: photo.title,
photoOrder: photo.order,
fileName: photo.file?.name,
retriesExhausted: true
}
});
// ✅ Graceful degradation: Track failure but continue
uploadFailures.push({ index: photoIndex, error: errorMsg, photo });
setFailedPhotos(prev => [...prev, { index: photoIndex, error: errorMsg }]);
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "failed" as const } : p)));
// ✅ Graceful degradation: Only throw if no partial success allowed
if (!UPLOAD_CONFIG.ALLOW_PARTIAL_SUCCESS) {
throw new Error(`Failed to upload ${photo.title || photo.file?.name || "photo"}: ${errorMsg}`);
}
}
}
}
// ✅ Graceful degradation: Check if we have any successful uploads
if (uploadedPhotos.length === 0 && photosToUpload.length > 0) {
throw new Error('All photo uploads failed. Please check your connection and try again.');
}
setUploadProgress(null);
// ✅ Graceful degradation: Log upload summary
logger.info('Photo upload phase complete', {
totalPhotos: photosToUpload.length,
successfulUploads: uploadedPhotos.length,
failedUploads: uploadFailures.length,
allowPartialSuccess: UPLOAD_CONFIG.ALLOW_PARTIAL_SUCCESS
});
// ✅ Phase 4: Validate uploaded photos before DB insertion
breadcrumb.userAction('Validate photos', 'handleSubmit', {
uploadedCount: uploadedPhotos.length,
failedCount: uploadFailures.length
});
// Only include successfully uploaded photos
const successfulPhotos = photos.filter(p =>
!p.file || // Already uploaded (no file)
uploadedPhotos.some(up => up.order === p.order) // Successfully uploaded
);
successfulPhotos.forEach((photo, index) => {
if (!photo.url) {
throw new Error(`Photo ${index + 1}: Missing URL`);
}
if (photo.uploadStatus === 'uploaded' && !photo.url.includes('/images/')) {
throw new Error(`Photo ${index + 1}: Invalid Cloudflare URL format`);
}
});
// ✅ Bulletproof: Create submission records with explicit retry configuration
breadcrumb.apiCall('create_submission_with_items', 'RPC');
await withRetry(
async () => {
// Create content_submission record first
const { data: submissionData, error: submissionError } = await supabase
.from("content_submissions")
.insert({
user_id: user.id,
submission_type: "photo",
content: {
partialSuccess: uploadFailures.length > 0,
successfulPhotos: uploadedPhotos.length,
failedPhotos: uploadFailures.length
},
})
.select()
.single();
if (submissionError || !submissionData) {
// ✅ Orphan cleanup: If DB fails, track uploaded images for cleanup
uploadedPhotos.forEach(p => {
if (p.cloudflare_id) {
setOrphanedCloudflareIds(prev => [...prev, p.cloudflare_id!]);
}
});
throw submissionError || new Error("Failed to create submission record");
}
// Create photo_submission record
const { data: photoSubmissionData, error: photoSubmissionError } = await supabase
.from("photo_submissions")
.insert({
submission_id: submissionData.id,
entity_type: entityType,
entity_id: entityId,
parent_id: parentId || null,
title: title.trim() || null,
})
.select()
.single();
if (photoSubmissionError || !photoSubmissionData) {
throw photoSubmissionError || new Error("Failed to create photo submission");
}
// Insert only successful photo items
const photoItems = successfulPhotos.map((photo, index) => ({
photo_submission_id: photoSubmissionData.id,
cloudflare_image_id: photo.cloudflare_id || photo.url.split("/").slice(-2, -1)[0] || "",
cloudflare_image_url: photo.url,
caption: photo.caption.trim() || null,
title: photo.title?.trim() || null,
filename: photo.file?.name || null,
order_index: index,
file_size: photo.file?.size || null,
mime_type: photo.file?.type || null,
}));
const { error: itemsError } = await supabase.from("photo_submission_items").insert(photoItems);
if (itemsError) {
throw itemsError;
}
logger.info('Photo submission created successfully', {
submissionId: submissionData.id,
photoCount: photoItems.length
});
},
{
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
baseDelay: UPLOAD_CONFIG.BASE_RETRY_DELAY,
maxDelay: UPLOAD_CONFIG.MAX_RETRY_DELAY,
shouldRetry: (error) => {
// ✅ Bulletproof: Intelligent retry for DB operations
if (error && typeof error === 'object') {
const pgError = error as { code?: string };
// Don't retry unique constraint violations or foreign key errors
if (pgError.code === '23505') return false; // unique_violation
if (pgError.code === '23503') return false; // foreign_key_violation
}
return isRetryableError(error);
},
onRetry: (attempt, error, delay) => {
logger.warn('Retrying photo submission DB insertion', {
attempt,
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
delay,
error: error instanceof Error ? error.message : String(error)
});
window.dispatchEvent(new CustomEvent('submission-retry', {
detail: {
id: crypto.randomUUID(),
attempt,
maxAttempts: UPLOAD_CONFIG.MAX_DB_ATTEMPTS,
delay,
type: 'photo submission database'
}
}));
}
}
);
// ✅ Graceful degradation: Inform user about partial success
if (uploadFailures.length > 0) {
toast({
title: "Partial Submission Successful",
description: `${uploadedPhotos.length} photo(s) submitted successfully. ${uploadFailures.length} photo(s) failed to upload.`,
variant: "default",
});
logger.warn('Partial photo submission success', {
successCount: uploadedPhotos.length,
failureCount: uploadFailures.length,
failures: uploadFailures.map(f => ({ index: f.index, error: f.error }))
});
} else {
toast({
title: "Submission Successful",
description: "Your photos have been submitted for review. Thank you for contributing!",
});
}
// ✅ Cleanup: Revoke blob URLs
photos.forEach((photo) => {
if (photo.url.startsWith("blob:")) {
URL.revokeObjectURL(photo.url);
}
});
// ✅ Cleanup: Log orphaned Cloudflare images for manual cleanup
if (orphanedCloudflareIds.length > 0) {
logger.warn('Orphaned Cloudflare images detected', {
cloudflareIds: orphanedCloudflareIds,
count: orphanedCloudflareIds.length,
note: 'These images were uploaded but submission failed - manual cleanup may be needed'
});
}
setTitle("");
setPhotos([]);
setFailedPhotos([]);
setOrphanedCloudflareIds([]);
onSubmissionComplete?.();
} catch (error: unknown) {
const errorMsg = sanitizeErrorMessage(error);
logger.error('Photo submission failed', {
error: errorMsg,
photoCount: photos.length,
uploadedCount: uploadedPhotos.length,
orphanedIds: orphanedCloudflareIds,
retriesExhausted: true
});
handleError(error, {
action: 'Submit Photo Submission',
userId: user?.id,
metadata: {
entityType,
entityId,
photoCount: photos.length,
uploadedPhotos: uploadedPhotos.length,
failedPhotos: failedPhotos.length,
orphanedCloudflareIds: orphanedCloudflareIds.length,
retriesExhausted: true
}
});
toast({
variant: "destructive",
title: "Submission Failed",
description: errorMsg || "There was an error submitting your photos. Please try again.",
});
} finally {
setIsSubmitting(false);
setUploadProgress(null);
}
};
// Cleanup on unmount
React.useEffect(() => {
return () => {
photos.forEach((photo) => {
if (photo.url.startsWith("blob:")) {
URL.revokeObjectURL(photo.url);
}
});
};
}, []);
const metadata = {
submissionType: "photo",
entityId,
entityType,
parentId,
userId: user?.id,
};
return (
<Card className="w-full max-w-2xl mx-auto shadow-lg border-accent/20 bg-accent/5">
<CardHeader className="text-center space-y-4">
<div className="mx-auto w-16 h-16 bg-accent/10 border-2 border-accent/30 rounded-full flex items-center justify-center">
<Camera className="w-8 h-8 text-accent" />
</div>
<div>
<CardTitle className="text-2xl text-accent">Submit Photos</CardTitle>
<CardDescription className="text-base mt-2">
Share your photos with the community. All submissions will be reviewed before being published.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-accent mt-0.5 flex-shrink-0" />
<div className="space-y-2 text-sm">
<p className="font-medium">Submission Guidelines:</p>
<ul className="space-y-1 text-muted-foreground">
<li> Photos should be clear and well-lit</li>
<li> Maximum 10 images per submission</li>
<li> Each image up to 25MB in size</li>
<li> Review process takes 24-48 hours</li>
</ul>
</div>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="title" className="text-base font-medium">
Title <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Give your photos a descriptive title (optional)"
maxLength={100}
disabled={isSubmitting}
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
/>
<p className="text-sm text-muted-foreground">{title.length}/100 characters</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">Photos *</Label>
{photos.length > 0 && (
<Badge variant="secondary" className="text-xs">
{photos.length} photo{photos.length !== 1 ? "s" : ""} selected
</Badge>
)}
</div>
<UppyPhotoUploadLazy
onFilesSelected={handleFilesSelected}
deferUpload={true}
maxFiles={10}
maxSizeMB={25}
metadata={metadata}
variant="public"
showPreview={false}
size="default"
enableDragDrop={true}
disabled={isSubmitting}
/>
</div>
{photos.length > 0 && (
<>
<Separator />
<PhotoCaptionEditor
photos={photos}
onPhotosChange={handlePhotosChange}
onRemovePhoto={handleRemovePhoto}
maxCaptionLength={200}
/>
</>
)}
<Separator />
<div className="space-y-4">
{uploadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Uploading photos...</span>
<span className="text-muted-foreground">
{uploadProgress.current} of {uploadProgress.total}
</span>
</div>
<Progress value={(uploadProgress.current / uploadProgress.total) * 100} />
{failedPhotos.length > 0 && (
<div className="flex items-start gap-2 text-sm text-destructive bg-destructive/10 p-2 rounded">
<XCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{failedPhotos.length} photo(s) failed - submission will continue with successful uploads</span>
</div>
)}
</div>
)}
<Button
onClick={handleSubmit}
disabled={isSubmitting || photos.length === 0}
className="w-full h-12 text-base font-medium bg-accent hover:bg-accent/90 text-accent-foreground"
size="lg"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
{uploadProgress ? `Uploading ${uploadProgress.current}/${uploadProgress.total}...` : "Submitting..."}
</div>
) : (
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5" />
Submit {photos.length} Photo{photos.length !== 1 ? "s" : ""}
</div>
)}
</Button>
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="w-4 h-4" />
<span>Your submission will be reviewed and published within 24-48 hours</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import { lazy, Suspense } from 'react';
import { UploadPlaceholder } from '@/components/loading/PageSkeletons';
import { UppyPhotoSubmissionUploadProps } from '@/types/submissions';
const UppyPhotoSubmissionUpload = lazy(() =>
import('./UppyPhotoSubmissionUpload').then(module => ({ default: module.UppyPhotoSubmissionUpload }))
);
export function UppyPhotoSubmissionUploadLazy(props: UppyPhotoSubmissionUploadProps) {
return (
<Suspense fallback={<UploadPlaceholder />}>
<UppyPhotoSubmissionUpload {...props} />
</Suspense>
);
}

View File

@@ -0,0 +1,424 @@
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>
);
}

View File

@@ -0,0 +1,35 @@
import { lazy, Suspense } from 'react';
import { UploadPlaceholder } from '@/components/loading/PageSkeletons';
import React from 'react';
const UppyPhotoUpload = lazy(() =>
import('./UppyPhotoUpload').then(module => ({ default: module.UppyPhotoUpload }))
);
export interface UppyPhotoUploadLazyProps {
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;
}
export function UppyPhotoUploadLazy(props: UppyPhotoUploadLazyProps) {
return (
<Suspense fallback={<UploadPlaceholder />}>
<UppyPhotoUpload {...props} />
</Suspense>
);
}