feat: gallery drag-and-drop reorder, grid layout fix, and svg upload support

- Add drag-and-drop image reordering using react-sortablejs (swap mode)
- Add sort_order column to event_images table
- Add PUT /api/event-images/reorder bulk reorder endpoint
- Fix gallery grid layout (items-start to remove bottom gap)
- Fix swapClass whitespace error (single CSS class)
- Increase upload limit from 10 to 50 images
- Add 50-image validation with user-friendly error message
- Add svg file support in multer file filter
- Remove auto-arrange feature
- Add empty state UI for gallery with no images
- Add drag handle and delete button on hover
This commit is contained in:
Ravindranbit 2026-04-28 22:00:41 +05:30
parent 83b891d8d8
commit ca698ad1df
4 changed files with 175 additions and 39 deletions

View File

@ -1,5 +1,5 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import Lightbox from 'yet-another-react-lightbox'; import Lightbox from 'yet-another-react-lightbox';
import Captions from 'yet-another-react-lightbox/plugins/captions'; import Captions from 'yet-another-react-lightbox/plugins/captions';
import Zoom from 'yet-another-react-lightbox/plugins/zoom'; import Zoom from 'yet-another-react-lightbox/plugins/zoom';
@ -8,8 +8,22 @@ import 'yet-another-react-lightbox/plugins/captions.css';
import axios from 'axios'; import axios from 'axios';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import IconTrashLines from '../icon/icon-trash-lines'; import IconTrashLines from '../icon/icon-trash-lines';
import IconMenu from '../icon/icon-menu';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import { buildApiUrl } from '@/utils/BaseUrl.utils'; import { buildApiUrl } from '@/utils/BaseUrl.utils';
import { ReactSortable } from 'react-sortablejs';
import Sortable, { Swap } from 'sortablejs';
if (typeof window !== 'undefined') {
Sortable.mount(new Swap());
}
interface GalleryImage {
id: number;
src: string;
title: string;
description: string;
}
interface EditEventFormProps { interface EditEventFormProps {
eventId: string | null; eventId: string | null;
@ -20,7 +34,12 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [photoIndex, setPhotoIndex] = useState(0); const [photoIndex, setPhotoIndex] = useState(0);
const [eventImages, setEventImages] = useState<any[]>([]); const [eventImages, setEventImages] = useState<GalleryImage[]>([]);
const latestImagesRef = useRef<GalleryImage[]>([]);
useEffect(() => {
latestImagesRef.current = eventImages;
}, [eventImages]);
useEffect(() => { useEffect(() => {
if (eventId) { if (eventId) {
@ -31,22 +50,57 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
const getEventGallery = async () => { const getEventGallery = async () => {
try { try {
const res = await axios.get(buildApiUrl(`event-images/event/${eventId}`)); const res = await axios.get(buildApiUrl(`event-images/event/${eventId}`));
const formatted = res.data?.data?.map((img: any) => ({ const formatted: GalleryImage[] = res.data?.data?.map((img: any) => ({
id: img.id, // ensure ID is preserved for deletion id: img.id,
src: img.imageurl, src: img.imageurl,
title: img?.title || '', title: img?.title || '',
description: img?.description || '', description: img?.description || '',
})); })) || [];
setEventImages(formatted || []); setEventImages(formatted);
} catch (error) { } catch (error) {
console.log("error", error); console.log('error', error);
} }
}; };
const showAlert = async (e: React.MouseEvent, item: any) => { const handleReorder = (newList: GalleryImage[]) => {
e.stopPropagation(); // ✅ Prevents triggering the parent onClick (lightbox) setEventImages(newList);
};
if (isOpen) setIsOpen(false); // optional: close lightbox if it's open const saveNewOrder = async (list: GalleryImage[]) => {
try {
const images = list.map((item, index) => ({
id: item.id,
sort_order: index,
}));
await axios.put(buildApiUrl('event-images/reorder'), { images });
Swal.fire({
title: 'Saved!',
text: 'Image order saved successfully.',
icon: 'success',
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 2000,
});
} catch (error: any) {
console.error('Failed to save order:', error);
const errMsg = error.response?.data?.message || error.message || 'Unknown error';
Swal.fire({
title: 'Error!',
text: `Failed to save the new order: ${errMsg}`,
icon: 'error',
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 5000,
});
}
};
const showAlert = async (e: React.MouseEvent, item: GalleryImage) => {
e.stopPropagation();
if (isOpen) setIsOpen(false);
Swal.fire({ Swal.fire({
icon: 'warning', icon: 'warning',
@ -66,7 +120,7 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
icon: 'success', icon: 'success',
customClass: { popup: 'sweet-alerts' }, customClass: { popup: 'sweet-alerts' },
}); });
getEventGallery(); // refresh after delete getEventGallery();
} catch (error) { } catch (error) {
Swal.fire({ Swal.fire({
title: 'Error!', title: 'Error!',
@ -81,45 +135,75 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
return ( return (
<div className="panel"> <div className="panel">
<div className='flex justify-between items-center'> {/* Header */}
<h5 className="mb-5 text-lg font-semibold dark:text-white-light">Gallery</h5> <div className="flex justify-between items-center mb-4">
<h5 className="text-lg font-semibold dark:text-white-light">Gallery</h5>
<button <button
type="button" type="button"
onClick={() => router.push(`/create-event-gallery?eventid=${eventId}`)} onClick={() => router.push(`/create-event-gallery?eventid=${eventId}`)}
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700" className="bg-blue-600 text-white px-4 py-2 rounded text-sm font-medium hover:bg-blue-700 transition-colors"
> >
Upload Images Upload Images
</button> </button>
</div> </div>
<div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> {/* Gallery Grid */}
{eventImages.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-400 py-20">
<svg className="w-16 h-16 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-lg font-medium">No images yet</p>
<p className="text-sm mt-1">Click "Upload Images" to add some!</p>
</div>
) : (
<>
<p className="text-xs text-gray-400 italic mb-4">Drag the icon to reorder images</p>
<ReactSortable
list={eventImages}
setList={handleReorder}
onEnd={() => saveNewOrder(latestImagesRef.current)}
animation={200}
handle=".drag-handle"
swap={true}
swapClass="swap-highlight"
className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 items-start"
>
{eventImages.map((item, index) => ( {eventImages.map((item, index) => (
<div <div
key={item.id} key={item.id}
className="cursor-pointer relative" className="cursor-pointer relative rounded-md border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden group"
onClick={() => { onClick={() => {
setPhotoIndex(index); setPhotoIndex(index);
setIsOpen(true); setIsOpen(true);
}} }}
> >
{/* Delete button (top-right corner) */} {/* Drag Handle */}
<div className="absolute top-5 right-5 flex gap-2 z-10"> <div className="drag-handle absolute top-2 left-2 z-20 cursor-move bg-white/80 dark:bg-black/80 rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-white dark:hover:bg-black transition-all shadow-sm">
<IconMenu className="w-4 h-4 text-gray-600 dark:text-gray-300" />
</div>
{/* Delete Button */}
<div className="absolute top-2 right-2 flex gap-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => showAlert(e, item)} onClick={(e) => showAlert(e, item)}
className="bg-red-600 text-white text-xs px-2 py-1 rounded" className="bg-red-600/80 hover:bg-red-600 text-white text-xs p-1.5 rounded-full shadow-sm transition-colors"
> >
<IconTrashLines /> <IconTrashLines className="w-4 h-4" />
</button> </button>
</div> </div>
<img <img
src={item.src} src={item.src}
alt={`gallery-${index}`} alt={`gallery-${index}`}
className="h-full w-full rounded-md object-cover" onDragStart={(e) => e.preventDefault()}
className="w-full h-auto"
/> />
</div> </div>
))} ))}
</div> </ReactSortable>
</>
)}
<Lightbox <Lightbox
styles={{ container: { backgroundColor: 'rgba(0,0,0,0.6)' } }} styles={{ container: { backgroundColor: 'rgba(0,0,0,0.6)' } }}

43
package-lock.json generated
View File

@ -30,6 +30,8 @@
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-sortablejs": "^6.1.4",
"sortablejs": "^1.15.7",
"sweetalert2": "^11.22.2", "sweetalert2": "^11.22.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"universal-cookie": "^7.2.0", "universal-cookie": "^7.2.0",
@ -1111,6 +1113,13 @@
"@babel/runtime": "^7.9.2" "@babel/runtime": "^7.9.2"
} }
}, },
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
"license": "MIT",
"peer": true
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@ -2046,6 +2055,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/classnames": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==",
"license": "MIT"
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -5512,6 +5527,22 @@
} }
} }
}, },
"node_modules/react-sortablejs": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.1.4.tgz",
"integrity": "sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
"tiny-invariant": "1.2.0"
},
"peerDependencies": {
"@types/sortablejs": "1",
"react": ">=16.9.0",
"react-dom": ">=16.9.0",
"sortablejs": "1"
}
},
"node_modules/read-cache": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -5817,6 +5848,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@ -6218,6 +6255,12 @@
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
}, },
"node_modules/tiny-invariant": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
"integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==",
"license": "MIT"
},
"node_modules/tiny-warning": { "node_modules/tiny-warning": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",

View File

@ -31,6 +31,8 @@
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-quill": "^2.0.0", "react-quill": "^2.0.0",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-sortablejs": "^6.1.4",
"sortablejs": "^1.15.7",
"sweetalert2": "^11.22.2", "sweetalert2": "^11.22.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"universal-cookie": "^7.2.0", "universal-cookie": "^7.2.0",

View File

@ -677,3 +677,10 @@ img.dark-img {
.dark img.dark-img { .dark img.dark-img {
@apply !block; @apply !block;
} }
/* Gallery drag-and-drop swap highlight */
.swap-highlight {
outline: 2px solid #f59e0b;
outline-offset: 2px;
opacity: 0.8;
}