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';
|
'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.map((item, index) => (
|
{eventImages.length === 0 ? (
|
||||||
<div
|
<div className="flex flex-col items-center justify-center text-gray-400 py-20">
|
||||||
key={item.id}
|
<svg className="w-16 h-16 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
className="cursor-pointer relative"
|
<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" />
|
||||||
onClick={() => {
|
</svg>
|
||||||
setPhotoIndex(index);
|
<p className="text-lg font-medium">No images yet</p>
|
||||||
setIsOpen(true);
|
<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) */}
|
{eventImages.map((item, index) => (
|
||||||
<div className="absolute top-5 right-5 flex gap-2 z-10">
|
<div
|
||||||
<button
|
key={item.id}
|
||||||
onClick={(e) => showAlert(e, item)}
|
className="cursor-pointer relative rounded-md border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden group"
|
||||||
className="bg-red-600 text-white text-xs px-2 py-1 rounded"
|
onClick={() => {
|
||||||
|
setPhotoIndex(index);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<IconTrashLines />
|
{/* Drag Handle */}
|
||||||
</button>
|
<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">
|
||||||
</div>
|
<IconMenu className="w-4 h-4 text-gray-600 dark:text-gray-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<img
|
{/* Delete Button */}
|
||||||
src={item.src}
|
<div className="absolute top-2 right-2 flex gap-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
alt={`gallery-${index}`}
|
<button
|
||||||
className="h-full w-full rounded-md object-cover"
|
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"
|
||||||
</div>
|
>
|
||||||
))}
|
<IconTrashLines className="w-4 h-4" />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={item.src}
|
||||||
|
alt={`gallery-${index}`}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
</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
43
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user