From 577c4bdf29e107d11044a218ed5a6ba359e060c7 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 17 May 2026 00:02:39 +0200 Subject: [PATCH] fix(upload): wire drag-and-drop on admin + user upload zones (#504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashed-border upload area in `PhotoUpload` (admin) and `UserPhotoUpload` (gallery user-upload) is styled and labelled as a drop zone — every locale's `upload.clickToUpload` already reads "Click to upload or drag and drop" or its translation — but neither component had any `onDragOver` / `onDragEnter` / `onDragLeave` / `onDrop` handlers. Files dropped on the zone fell through to the browser's default behaviour (open the image in a new tab), which is what Rekoo-PS reported. Added native HTML5 drag-and-drop wiring on both components, plumbed through the same filter/limit/toast pipeline used by the click path (`addFiles` helper). Visual highlight on drag-over via an `isDragOver` flag; the listener guards against the `dragleave` strobing that fires on every child node. Also reset the `` value after onChange so re-picking the same file still triggers an upload — matches the new drop-then-pick mental model. --- frontend/src/components/admin/PhotoUpload.tsx | 69 +++++++++++++++---- .../components/gallery/UserPhotoUpload.tsx | 53 +++++++++++--- 2 files changed, 101 insertions(+), 21 deletions(-) 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). */}