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:
@@ -79,14 +79,16 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
);
|
);
|
||||||
|
|
||||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
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;
|
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||||
if (totalFiles > maxFilesPerUpload) {
|
if (totalFiles > maxFilesPerUpload) {
|
||||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||||
@@ -101,11 +103,44 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||||
`Only ${allowedNewFiles} more files can be added (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;
|
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) => {
|
const removeFile = (index: number) => {
|
||||||
@@ -349,14 +384,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Input Area */}
|
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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",
|
"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()}
|
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" />
|
<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">
|
<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…"
|
// bytes-on-wire for that file, so the UI can show "Processing…"
|
||||||
// instead of a static 100% bar while the backend works.
|
// instead of a static 100% bar while the backend works.
|
||||||
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
|
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const { data: publicSettings } = usePublicSettings();
|
const { data: publicSettings } = usePublicSettings();
|
||||||
|
|
||||||
@@ -41,11 +42,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
[publicSettings?.allowed_file_types]
|
[publicSettings?.allowed_file_types]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
|
||||||
const selectedFiles = Array.from(e.target.files || []);
|
const addFiles = (incoming: File[]) => {
|
||||||
|
const validFiles = incoming.filter((file) => {
|
||||||
// Validate file types
|
|
||||||
const validFiles = selectedFiles.filter(file => {
|
|
||||||
if (!allowedMimeTypes.includes(file.type)) {
|
if (!allowedMimeTypes.includes(file.type)) {
|
||||||
toast.error(`Invalid file type: ${file.name}`);
|
toast.error(`Invalid file type: ${file.name}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -57,8 +56,38 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
}
|
}
|
||||||
return true;
|
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) => {
|
const removeFile = (index: number) => {
|
||||||
@@ -154,10 +183,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
|
<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">
|
<div className="mb-4 sm:mb-6">
|
||||||
<label className="block">
|
<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" />
|
<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">
|
<p className="text-sm font-medium text-muted-theme mb-1">
|
||||||
{t('upload.clickToUpload')}
|
{t('upload.clickToUpload')}
|
||||||
|
|||||||
Reference in New Issue
Block a user