Implement photo upload enhancements

This commit is contained in:
gpt-engineer-app[bot]
2025-09-29 17:13:17 +00:00
parent 343d9c934c
commit 1542683456
4 changed files with 528 additions and 62 deletions

View File

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

View File

@@ -0,0 +1,181 @@
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 { X, Eye, GripVertical, Edit3 } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface PhotoWithCaption {
url: string;
caption: string;
title?: string;
order: number;
}
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 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={`caption-${index}`} className="text-xs">
Caption
</Label>
<Input
id={`caption-${index}`}
value={photo.caption}
onChange={(e) => updatePhotoCaption(index, e.target.value)}
placeholder="Add a caption for 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>
)}
<p className="text-sm text-muted-foreground">
{photo.caption || (
<span className="italic">Click edit to add caption</span>
)}
</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { UppyPhotoUpload } from './UppyPhotoUpload';
import { PhotoCaptionEditor, PhotoWithCaption } from './PhotoCaptionEditor';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
@@ -24,14 +25,28 @@ export function UppyPhotoSubmissionUpload({
rideId,
}: UppyPhotoSubmissionUploadProps) {
const [title, setTitle] = useState('');
const [caption, setCaption] = useState('');
const [uploadedUrls, setUploadedUrls] = useState<string[]>([]);
const [description, setDescription] = useState('');
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { user } = useAuth();
const { toast } = useToast();
const handleUploadComplete = (urls: string[]) => {
setUploadedUrls(urls);
// Convert URLs to photo objects with empty captions
const newPhotos: PhotoWithCaption[] = urls.map((url, index) => ({
url,
caption: '',
order: photos.length + index,
}));
setPhotos(prev => [...prev, ...newPhotos]);
};
const handlePhotosChange = (updatedPhotos: PhotoWithCaption[]) => {
setPhotos(updatedPhotos);
};
const handleRemovePhoto = (index: number) => {
setPhotos(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
@@ -44,7 +59,7 @@ export function UppyPhotoSubmissionUpload({
return;
}
if (uploadedUrls.length === 0) {
if (photos.length === 0) {
toast({
variant: 'destructive',
title: 'No Photos',
@@ -70,9 +85,11 @@ export function UppyPhotoSubmissionUpload({
submission_type: 'photo',
content: {
title: title.trim(),
caption: caption.trim(),
photos: uploadedUrls.map((url, index) => ({
url,
description: description.trim(),
photos: photos.map((photo, index) => ({
url: photo.url,
caption: photo.caption.trim(),
title: photo.title?.trim(),
order: index,
})),
context: {
@@ -97,8 +114,8 @@ export function UppyPhotoSubmissionUpload({
// Reset form
setTitle('');
setCaption('');
setUploadedUrls([]);
setDescription('');
setPhotos([]);
onSubmissionComplete?.();
} catch (error) {
console.error('Submission error:', error);
@@ -172,29 +189,29 @@ export function UppyPhotoSubmissionUpload({
</div>
<div className="space-y-2">
<Label htmlFor="caption" className="text-base font-medium">
Caption
<Label htmlFor="description" className="text-base font-medium">
Description
</Label>
<Textarea
id="caption"
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Add a description or story about these photos..."
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a general description about these photos..."
maxLength={500}
rows={3}
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20 resize-none"
/>
<p className="text-sm text-muted-foreground">
{caption.length}/500 characters
{description.length}/500 characters
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">Photos *</Label>
{uploadedUrls.length > 0 && (
{photos.length > 0 && (
<Badge variant="secondary" className="text-xs">
{uploadedUrls.length} photo{uploadedUrls.length !== 1 ? 's' : ''} selected
{photos.length} photo{photos.length !== 1 ? 's' : ''} selected
</Badge>
)}
</div>
@@ -204,17 +221,30 @@ export function UppyPhotoSubmissionUpload({
maxSizeMB={25}
metadata={metadata}
variant="public"
showPreview={true}
showPreview={false}
size="default"
enableDragDrop={true}
/>
</div>
{photos.length > 0 && (
<>
<Separator />
<PhotoCaptionEditor
photos={photos}
onPhotosChange={handlePhotosChange}
onRemovePhoto={handleRemovePhoto}
maxCaptionLength={200}
/>
</>
)}
<Separator />
<div className="space-y-4">
<Button
onClick={handleSubmit}
disabled={isSubmitting || !title.trim() || uploadedUrls.length === 0}
disabled={isSubmitting || !title.trim() || photos.length === 0}
className="w-full h-12 text-base font-medium photo-upload-trigger"
size="lg"
>
@@ -226,7 +256,7 @@ export function UppyPhotoSubmissionUpload({
) : (
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5" />
Submit {uploadedUrls.length} Photo{uploadedUrls.length !== 1 ? 's' : ''}
Submit {photos.length} Photo{photos.length !== 1 ? 's' : ''}
</div>
)}
</Button>

View File

@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Upload, X, Eye, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { DragDropZone } from './DragDropZone';
interface UppyPhotoUploadProps {
onUploadComplete?: (urls: string[]) => void;
@@ -25,6 +26,8 @@ interface UppyPhotoUploadProps {
disabled?: boolean;
showPreview?: boolean;
size?: 'default' | 'compact' | 'large';
enableDragDrop?: boolean;
showUploadModal?: boolean;
}
interface CloudflareResponse {
@@ -59,6 +62,8 @@ export function UppyPhotoUpload({
disabled = false,
showPreview = true,
size = 'default',
enableDragDrop = true,
showUploadModal = true,
}: UppyPhotoUploadProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
@@ -167,45 +172,44 @@ export function UppyPhotoUpload({
setUploadProgress(Math.round(progress));
});
// Handle upload success
uppy.on('upload-success', async (file, response) => {
try {
const cloudflareId = file?.meta?.cloudflareId as string;
if (!cloudflareId) {
throw new Error('Missing Cloudflare ID');
}
// Poll for upload completion
let attempts = 0;
const maxAttempts = 30; // 30 seconds max
let imageData: UploadSuccessResponse | null = null;
while (attempts < maxAttempts) {
const statusResponse = await supabase.functions.invoke('upload-image', {
body: null,
method: 'GET',
});
if (!statusResponse.error && statusResponse.data) {
const status: UploadSuccessResponse = statusResponse.data;
if (status.uploaded && status.urls) {
imageData = status;
break;
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
}
if (imageData?.urls) {
setUploadedImages(prev => [...prev, imageData.urls!.public]);
}
} catch (error) {
console.error('Upload post-processing failed:', error);
onUploadError?.(error as Error);
// Handle upload success
uppy.on('upload-success', async (file, response) => {
try {
const cloudflareId = file?.meta?.cloudflareId as string;
if (!cloudflareId) {
throw new Error('Missing Cloudflare ID');
}
});
// Poll for upload completion with the correct ID
let attempts = 0;
const maxAttempts = 30; // 30 seconds max
let imageData: UploadSuccessResponse | null = null;
while (attempts < maxAttempts) {
const statusResponse = await supabase.functions.invoke('upload-image', {
body: { id: cloudflareId },
});
if (!statusResponse.error && statusResponse.data) {
const status: UploadSuccessResponse = statusResponse.data;
if (status.uploaded && status.urls) {
imageData = status;
break;
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
}
if (imageData?.urls) {
setUploadedImages(prev => [...prev, imageData.urls!.public]);
}
} catch (error) {
console.error('Upload post-processing failed:', error);
onUploadError?.(error as Error);
}
});
// Handle upload error
uppy.on('upload-error', (file, error, response) => {
@@ -263,6 +267,20 @@ export function UppyPhotoUpload({
};
}, [maxFiles, maxSizeMB, allowedFileTypes, metadata, variant, disabled, onUploadStart, onUploadComplete, onUploadError, toast, uploadedImages, size]);
const handleDragDropFiles = async (files: File[]) => {
if (!uppyRef.current || disabled) return;
// Add files to Uppy and start upload
files.forEach((file) => {
uppyRef.current?.addFile({
source: 'drag-drop',
name: file.name,
type: file.type,
data: file,
});
});
};
const removeImage = (index: number) => {
const newUrls = uploadedImages.filter((_, i) => i !== index);
setUploadedImages(newUrls);
@@ -367,12 +385,50 @@ export function UppyPhotoUpload({
);
};
const renderContent = () => {
if (enableDragDrop && !children) {
return (
<DragDropZone
onFilesAdded={handleDragDropFiles}
maxFiles={maxFiles}
maxSizeMB={maxSizeMB}
allowedFileTypes={allowedFileTypes}
disabled={disabled}
className="min-h-[200px]"
>
<div className="space-y-4">
{renderUploadTrigger()}
{renderPreview()}
</div>
</DragDropZone>
);
}
return (
<div className="space-y-4">
{enableDragDrop && children ? (
<DragDropZone
onFilesAdded={handleDragDropFiles}
maxFiles={maxFiles}
maxSizeMB={maxSizeMB}
allowedFileTypes={allowedFileTypes}
disabled={disabled}
>
{renderUploadTrigger()}
</DragDropZone>
) : (
renderUploadTrigger()
)}
{renderPreview()}
</div>
);
};
return (
<div className={cn("space-y-4", className)}>
{renderUploadTrigger()}
{renderPreview()}
{renderContent()}
{uppyRef.current && (
{uppyRef.current && showUploadModal && (
<DashboardModal
uppy={uppyRef.current}
open={isModalOpen}