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:
parent
83b891d8d8
commit
ca698ad1df
@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Lightbox from 'yet-another-react-lightbox';
|
||||
import Captions from 'yet-another-react-lightbox/plugins/captions';
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import IconTrashLines from '../icon/icon-trash-lines';
|
||||
import IconMenu from '../icon/icon-menu';
|
||||
import Swal from 'sweetalert2';
|
||||
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 {
|
||||
eventId: string | null;
|
||||
@ -20,7 +34,12 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
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(() => {
|
||||
if (eventId) {
|
||||
@ -31,22 +50,57 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
|
||||
const getEventGallery = async () => {
|
||||
try {
|
||||
const res = await axios.get(buildApiUrl(`event-images/event/${eventId}`));
|
||||
const formatted = res.data?.data?.map((img: any) => ({
|
||||
id: img.id, // ensure ID is preserved for deletion
|
||||
const formatted: GalleryImage[] = res.data?.data?.map((img: any) => ({
|
||||
id: img.id,
|
||||
src: img.imageurl,
|
||||
title: img?.title || '',
|
||||
description: img?.description || '',
|
||||
}));
|
||||
setEventImages(formatted || []);
|
||||
})) || [];
|
||||
setEventImages(formatted);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
console.log('error', error);
|
||||
}
|
||||
};
|
||||
|
||||
const showAlert = async (e: React.MouseEvent, item: any) => {
|
||||
e.stopPropagation(); // ✅ Prevents triggering the parent onClick (lightbox)
|
||||
const handleReorder = (newList: GalleryImage[]) => {
|
||||
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({
|
||||
icon: 'warning',
|
||||
@ -66,7 +120,7 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
|
||||
icon: 'success',
|
||||
customClass: { popup: 'sweet-alerts' },
|
||||
});
|
||||
getEventGallery(); // refresh after delete
|
||||
getEventGallery();
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
title: 'Error!',
|
||||
@ -81,45 +135,75 @@ const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className='flex justify-between items-center'>
|
||||
<h5 className="mb-5 text-lg font-semibold dark:text-white-light">Gallery</h5>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h5 className="text-lg font-semibold dark:text-white-light">Gallery</h5>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{eventImages.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="cursor-pointer relative"
|
||||
onClick={() => {
|
||||
setPhotoIndex(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
{/* 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"
|
||||
>
|
||||
{/* Delete button (top-right corner) */}
|
||||
<div className="absolute top-5 right-5 flex gap-2 z-10">
|
||||
<button
|
||||
onClick={(e) => showAlert(e, item)}
|
||||
className="bg-red-600 text-white text-xs px-2 py-1 rounded"
|
||||
{eventImages.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="cursor-pointer relative rounded-md border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden group"
|
||||
onClick={() => {
|
||||
setPhotoIndex(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
<IconTrashLines />
|
||||
</button>
|
||||
</div>
|
||||
{/* Drag Handle */}
|
||||
<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>
|
||||
|
||||
<img
|
||||
src={item.src}
|
||||
alt={`gallery-${index}`}
|
||||
className="h-full w-full rounded-md object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Delete Button */}
|
||||
<div className="absolute top-2 right-2 flex gap-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => showAlert(e, item)}
|
||||
className="bg-red-600/80 hover:bg-red-600 text-white text-xs p-1.5 rounded-full shadow-sm transition-colors"
|
||||
>
|
||||
<IconTrashLines className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={item.src}
|
||||
alt={`gallery-${index}`}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ReactSortable>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Lightbox
|
||||
styles={{ container: { backgroundColor: 'rgba(0,0,0,0.6)' } }}
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@ -30,6 +30,8 @@
|
||||
"react-popper": "^2.3.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-sortablejs": "^6.1.4",
|
||||
"sortablejs": "^1.15.7",
|
||||
"sweetalert2": "^11.22.2",
|
||||
"typescript": "^5.3.3",
|
||||
"universal-cookie": "^7.2.0",
|
||||
@ -1111,6 +1113,13 @@
|
||||
"@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": {
|
||||
"version": "0.0.3",
|
||||
"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_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": {
|
||||
"version": "0.0.1",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@ -5817,6 +5848,12 @@
|
||||
"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": {
|
||||
"version": "0.5.7",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
|
||||
@ -31,6 +31,8 @@
|
||||
"react-popper": "^2.3.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-sortablejs": "^6.1.4",
|
||||
"sortablejs": "^1.15.7",
|
||||
"sweetalert2": "^11.22.2",
|
||||
"typescript": "^5.3.3",
|
||||
"universal-cookie": "^7.2.0",
|
||||
|
||||
@ -677,3 +677,10 @@ img.dark-img {
|
||||
.dark img.dark-img {
|
||||
@apply !block;
|
||||
}
|
||||
|
||||
/* Gallery drag-and-drop swap highlight */
|
||||
.swap-highlight {
|
||||
outline: 2px solid #f59e0b;
|
||||
outline-offset: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user