mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 01:07:04 -05:00
Implement comprehensive error recovery mechanisms for the photo upload pipeline in `UppyPhotoSubmissionUpload.tsx`. This includes adding exponential backoff to retries, graceful degradation for partial uploads, and cleanup for orphaned Cloudflare images. The changes also enhance error tracking and user feedback for failed uploads.
243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
} |