Files
thrilltrack-explorer/src/components/upload/PhotoCaptionEditor.tsx
gpt-engineer-app[bot] c79538707c Refactor photo upload pipeline
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.
2025-11-08 00:11:55 +00:00

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>
);
}