From 15426834564cdf4bad8ef2be597d09907ce4f628 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 17:13:17 +0000 Subject: [PATCH] Implement photo upload enhancements --- src/components/upload/DragDropZone.tsx | 199 ++++++++++++++++++ src/components/upload/PhotoCaptionEditor.tsx | 181 ++++++++++++++++ .../upload/UppyPhotoSubmissionUpload.tsx | 72 +++++-- src/components/upload/UppyPhotoUpload.tsx | 138 ++++++++---- 4 files changed, 528 insertions(+), 62 deletions(-) create mode 100644 src/components/upload/DragDropZone.tsx create mode 100644 src/components/upload/PhotoCaptionEditor.tsx diff --git a/src/components/upload/DragDropZone.tsx b/src/components/upload/DragDropZone.tsx new file mode 100644 index 00000000..3e5b5854 --- /dev/null +++ b/src/components/upload/DragDropZone.tsx @@ -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) => { + 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 ( +
+ + {children} +
+ ); + } + + return ( +
+ + +
+
+ {isDragOver ? ( + + ) : ( + + )} +
+ +
+

+ {isDragOver ? 'Drop files here' : 'Drag & drop photos here'} +

+

+ or click to browse files +

+

+ Max {maxFiles} files, {maxSizeMB}MB each +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/upload/PhotoCaptionEditor.tsx b/src/components/upload/PhotoCaptionEditor.tsx new file mode 100644 index 00000000..eefc14cc --- /dev/null +++ b/src/components/upload/PhotoCaptionEditor.tsx @@ -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(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 ( +
+
+ + + {photos.length} photo{photos.length !== 1 ? 's' : ''} + +
+ +
+ {photos.map((photo, index) => ( + + +
+ {/* Photo preview */} +
+ {photo.title +
+ + +
+
+ + {/* Caption editing */} +
+
+ + Photo {index + 1} + +
+ +
+ +
+
+
+ + {editingIndex === index ? ( +
+
+ + updatePhotoTitle(index, e.target.value)} + placeholder="Photo title" + maxLength={100} + className="h-8 text-sm" + /> +
+
+ + updatePhotoCaption(index, e.target.value)} + placeholder="Add a caption for this photo..." + maxLength={maxCaptionLength} + className="h-8 text-sm" + /> +

+ {photo.caption.length}/{maxCaptionLength} characters +

+
+
+ ) : ( +
+ {photo.title && ( +

{photo.title}

+ )} +

+ {photo.caption || ( + Click edit to add caption + )} +

+
+ )} +
+
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/upload/UppyPhotoSubmissionUpload.tsx b/src/components/upload/UppyPhotoSubmissionUpload.tsx index ccbc0671..65ad909e 100644 --- a/src/components/upload/UppyPhotoSubmissionUpload.tsx +++ b/src/components/upload/UppyPhotoSubmissionUpload.tsx @@ -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([]); + const [description, setDescription] = useState(''); + const [photos, setPhotos] = useState([]); 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({
-