diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index b5c5d43f..f193c6a2 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -79,14 +79,16 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl ); const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0); + const [isDragOver, setIsDragOver] = useState(false); + + // Shared filter + per-upload-limit pipeline used by both the file-input + // change handler and the drop handler. #504 — without the drop handler + // the dashed-border zone looked draggable but silently fell through to + // the browser's default "open the file in a new tab" behaviour. + const addFiles = (incoming: File[]) => { + const imageFiles = incoming.filter((file) => allowedMimeTypes.includes(file.type)); + if (imageFiles.length === 0) return; - const handleFileSelect = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - const imageFiles = files.filter(file => - allowedMimeTypes.includes(file.type) - ); - - // Check total file count with existing files const totalFiles = selectedFiles.length + imageFiles.length; if (totalFiles > maxFilesPerUpload) { const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; @@ -101,11 +103,44 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || `Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})` ); - setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); + setSelectedFiles((prev) => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); return; } - - setSelectedFiles(prev => [...prev, ...imageFiles]); + + setSelectedFiles((prev) => [...prev, ...imageFiles]); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + addFiles(Array.from(e.target.files || [])); + // Reset the input so picking the same files again still fires onChange. + if (e.target.value) e.target.value = ''; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // dropEffect must be set on every dragover for the cursor to render + // the "copy" affordance in Chrome/Firefox. + e.dataTransfer.dropEffect = 'copy'; + if (!isDragOver) setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // dragleave fires for every child node the cursor passes — only flip + // the highlight off when the cursor leaves the zone itself, otherwise + // it strobes on/off as the user moves over the icon and text. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + const files = Array.from(e.dataTransfer.files || []); + addFiles(files); }; const removeFile = (index: number) => { @@ -349,14 +384,22 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl - {/* File Input Area */} + {/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600" + isDragOver + ? "border-accent-dark bg-accent-dark/25" + : selectedFiles.length > 0 + ? "border-accent-dark bg-accent-dark/15" + : "border-neutral-300 dark:border-neutral-600" )} onClick={() => fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} >

diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index bb1661a7..791aa624 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -28,6 +28,7 @@ export const UserPhotoUpload: React.FC = ({ // bytes-on-wire for that file, so the UI can show "Processing…" // instead of a static 100% bar while the backend works. const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({}); + const [isDragOver, setIsDragOver] = useState(false); const { data: publicSettings } = usePublicSettings(); @@ -41,11 +42,9 @@ export const UserPhotoUpload: React.FC = ({ [publicSettings?.allowed_file_types] ); - const handleFileSelect = (e: React.ChangeEvent) => { - const selectedFiles = Array.from(e.target.files || []); - - // Validate file types - const validFiles = selectedFiles.filter(file => { + // Shared filter pipeline for both change and drag-and-drop (#504). + const addFiles = (incoming: File[]) => { + const validFiles = incoming.filter((file) => { if (!allowedMimeTypes.includes(file.type)) { toast.error(`Invalid file type: ${file.name}`); return false; @@ -57,8 +56,38 @@ export const UserPhotoUpload: React.FC = ({ } return true; }); + if (validFiles.length === 0) return; + setFiles((prev) => [...prev, ...validFiles]); + }; - setFiles(prev => [...prev, ...validFiles]); + const handleFileSelect = (e: React.ChangeEvent) => { + addFiles(Array.from(e.target.files || [])); + // Reset so re-selecting the same file fires onChange again. + if (e.target.value) e.target.value = ''; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + if (!isDragOver) setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // dragleave fires for every child node — only flip off when the cursor + // leaves the zone itself. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + if (uploading) return; + addFiles(Array.from(e.dataTransfer.files || [])); }; const removeFile = (index: number) => { @@ -154,10 +183,18 @@ export const UserPhotoUpload: React.FC = ({ {/* Scrollable Content */}

- {/* Upload Area */} + {/* Upload Area — accepts both click-to-pick and drag-and-drop (#504). */}