mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 00:11:14 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
199
src-old/components/upload/DragDropZone.tsx
Normal file
199
src-old/components/upload/DragDropZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src-old/components/upload/EntityImageUploader.tsx
Normal file
211
src-old/components/upload/EntityImageUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
src-old/components/upload/EntityMultiImageUploader.tsx
Normal file
425
src-old/components/upload/EntityMultiImageUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
src-old/components/upload/EntityPhotoGallery.tsx
Normal file
260
src-old/components/upload/EntityPhotoGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src-old/components/upload/PhotoCaptionEditor.tsx
Normal file
243
src-old/components/upload/PhotoCaptionEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
410
src-old/components/upload/PhotoManagementDialog.tsx
Normal file
410
src-old/components/upload/PhotoManagementDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
535
src-old/components/upload/PhotoUpload.tsx
Normal file
535
src-old/components/upload/PhotoUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
713
src-old/components/upload/UppyPhotoSubmissionUpload.tsx
Normal file
713
src-old/components/upload/UppyPhotoSubmissionUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src-old/components/upload/UppyPhotoSubmissionUploadLazy.tsx
Normal file
15
src-old/components/upload/UppyPhotoSubmissionUploadLazy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
424
src-old/components/upload/UppyPhotoUpload.tsx
Normal file
424
src-old/components/upload/UppyPhotoUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src-old/components/upload/UppyPhotoUploadLazy.tsx
Normal file
35
src-old/components/upload/UppyPhotoUploadLazy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user