mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 18:31:13 -05:00
Implement photo upload enhancements
This commit is contained in:
199
src/components/upload/DragDropZone.tsx
Normal file
199
src/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-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
src/components/upload/PhotoCaptionEditor.tsx
Normal file
181
src/components/upload/PhotoCaptionEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { UppyPhotoUpload } from './UppyPhotoUpload';
|
import { UppyPhotoUpload } from './UppyPhotoUpload';
|
||||||
|
import { PhotoCaptionEditor, PhotoWithCaption } from './PhotoCaptionEditor';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -24,14 +25,28 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
rideId,
|
rideId,
|
||||||
}: UppyPhotoSubmissionUploadProps) {
|
}: UppyPhotoSubmissionUploadProps) {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [caption, setCaption] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [uploadedUrls, setUploadedUrls] = useState<string[]>([]);
|
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const handleUploadComplete = (urls: string[]) => {
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
@@ -44,7 +59,7 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadedUrls.length === 0) {
|
if (photos.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'No Photos',
|
title: 'No Photos',
|
||||||
@@ -70,9 +85,11 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
submission_type: 'photo',
|
submission_type: 'photo',
|
||||||
content: {
|
content: {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
caption: caption.trim(),
|
description: description.trim(),
|
||||||
photos: uploadedUrls.map((url, index) => ({
|
photos: photos.map((photo, index) => ({
|
||||||
url,
|
url: photo.url,
|
||||||
|
caption: photo.caption.trim(),
|
||||||
|
title: photo.title?.trim(),
|
||||||
order: index,
|
order: index,
|
||||||
})),
|
})),
|
||||||
context: {
|
context: {
|
||||||
@@ -97,8 +114,8 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setCaption('');
|
setDescription('');
|
||||||
setUploadedUrls([]);
|
setPhotos([]);
|
||||||
onSubmissionComplete?.();
|
onSubmissionComplete?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submission error:', error);
|
console.error('Submission error:', error);
|
||||||
@@ -172,29 +189,29 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="caption" className="text-base font-medium">
|
<Label htmlFor="description" className="text-base font-medium">
|
||||||
Caption
|
Description
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="caption"
|
id="description"
|
||||||
value={caption}
|
value={description}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Add a description or story about these photos..."
|
placeholder="Add a general description about these photos..."
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20 resize-none"
|
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20 resize-none"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{caption.length}/500 characters
|
{description.length}/500 characters
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-base font-medium">Photos *</Label>
|
<Label className="text-base font-medium">Photos *</Label>
|
||||||
{uploadedUrls.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{uploadedUrls.length} photo{uploadedUrls.length !== 1 ? 's' : ''} selected
|
{photos.length} photo{photos.length !== 1 ? 's' : ''} selected
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,17 +221,30 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
maxSizeMB={25}
|
maxSizeMB={25}
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
variant="public"
|
variant="public"
|
||||||
showPreview={true}
|
showPreview={false}
|
||||||
size="default"
|
size="default"
|
||||||
|
enableDragDrop={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<PhotoCaptionEditor
|
||||||
|
photos={photos}
|
||||||
|
onPhotosChange={handlePhotosChange}
|
||||||
|
onRemovePhoto={handleRemovePhoto}
|
||||||
|
maxCaptionLength={200}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
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"
|
className="w-full h-12 text-base font-medium photo-upload-trigger"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -226,7 +256,7 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-5 h-5" />
|
<CheckCircle className="w-5 h-5" />
|
||||||
Submit {uploadedUrls.length} Photo{uploadedUrls.length !== 1 ? 's' : ''}
|
Submit {photos.length} Photo{photos.length !== 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Upload, X, Eye, Loader2 } from 'lucide-react';
|
import { Upload, X, Eye, Loader2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DragDropZone } from './DragDropZone';
|
||||||
|
|
||||||
interface UppyPhotoUploadProps {
|
interface UppyPhotoUploadProps {
|
||||||
onUploadComplete?: (urls: string[]) => void;
|
onUploadComplete?: (urls: string[]) => void;
|
||||||
@@ -25,6 +26,8 @@ interface UppyPhotoUploadProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
showPreview?: boolean;
|
showPreview?: boolean;
|
||||||
size?: 'default' | 'compact' | 'large';
|
size?: 'default' | 'compact' | 'large';
|
||||||
|
enableDragDrop?: boolean;
|
||||||
|
showUploadModal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloudflareResponse {
|
interface CloudflareResponse {
|
||||||
@@ -59,6 +62,8 @@ export function UppyPhotoUpload({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
showPreview = true,
|
showPreview = true,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
|
enableDragDrop = true,
|
||||||
|
showUploadModal = true,
|
||||||
}: UppyPhotoUploadProps) {
|
}: UppyPhotoUploadProps) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
||||||
@@ -175,15 +180,14 @@ export function UppyPhotoUpload({
|
|||||||
throw new Error('Missing Cloudflare ID');
|
throw new Error('Missing Cloudflare ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for upload completion
|
// Poll for upload completion with the correct ID
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = 30; // 30 seconds max
|
const maxAttempts = 30; // 30 seconds max
|
||||||
let imageData: UploadSuccessResponse | null = null;
|
let imageData: UploadSuccessResponse | null = null;
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
const statusResponse = await supabase.functions.invoke('upload-image', {
|
const statusResponse = await supabase.functions.invoke('upload-image', {
|
||||||
body: null,
|
body: { id: cloudflareId },
|
||||||
method: 'GET',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!statusResponse.error && statusResponse.data) {
|
if (!statusResponse.error && statusResponse.data) {
|
||||||
@@ -263,6 +267,20 @@ export function UppyPhotoUpload({
|
|||||||
};
|
};
|
||||||
}, [maxFiles, maxSizeMB, allowedFileTypes, metadata, variant, disabled, onUploadStart, onUploadComplete, onUploadError, toast, uploadedImages, size]);
|
}, [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 removeImage = (index: number) => {
|
||||||
const newUrls = uploadedImages.filter((_, i) => i !== index);
|
const newUrls = uploadedImages.filter((_, i) => i !== index);
|
||||||
setUploadedImages(newUrls);
|
setUploadedImages(newUrls);
|
||||||
@@ -367,12 +385,50 @@ export function UppyPhotoUpload({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (enableDragDrop && !children) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<DragDropZone
|
||||||
|
onFilesAdded={handleDragDropFiles}
|
||||||
|
maxFiles={maxFiles}
|
||||||
|
maxSizeMB={maxSizeMB}
|
||||||
|
allowedFileTypes={allowedFileTypes}
|
||||||
|
disabled={disabled}
|
||||||
|
className="min-h-[200px]"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
{renderUploadTrigger()}
|
{renderUploadTrigger()}
|
||||||
{renderPreview()}
|
{renderPreview()}
|
||||||
|
</div>
|
||||||
|
</DragDropZone>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{uppyRef.current && (
|
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)}>
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{uppyRef.current && showUploadModal && (
|
||||||
<DashboardModal
|
<DashboardModal
|
||||||
uppy={uppyRef.current}
|
uppy={uppyRef.current}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user