fix(upload): wire drag-and-drop on admin + user upload zones (#504)

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 `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
This commit is contained in:
Paul Nothaft
2026-05-17 00:02:39 +02:00
parent 3a490844e1
commit 577c4bdf29
2 changed files with 101 additions and 21 deletions
+56 -13
View File
@@ -79,14 +79,16 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ 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<HTMLInputElement>) => {
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<PhotoUploadProps> = ({ 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<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<PhotoUploadProps> = ({ eventId, onUploadCompl
</label>
</div>
{/* File Input Area */}
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
"hover:border-accent-dark hover:bg-accent-dark/15",
selectedFiles.length > 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}
>
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
@@ -28,6 +28,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
// 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<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types]
);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Validate file types
const validFiles = selectedFiles.filter(file => {
// Shared filter pipeline for both <input> 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<UserPhotoUploadProps> = ({
}
return true;
});
if (validFiles.length === 0) return;
setFiles((prev) => [...prev, ...validFiles]);
};
setFiles(prev => [...prev, ...validFiles]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragOver) setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<UserPhotoUploadProps> = ({
{/* Scrollable Content */}
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
{/* Upload Area */}
{/* Upload Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div className="mb-4 sm:mb-6">
<label className="block">
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer">
<div
className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer ${
isDragOver ? 'border-accent-dark bg-accent-dark/10' : 'border-surface'
}`}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-sm font-medium text-muted-theme mb-1">
{t('upload.clickToUpload')}