- 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
221 lines
8.7 KiB
TypeScript
221 lines
8.7 KiB
TypeScript
'use client';
|
|
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';
|
|
import 'yet-another-react-lightbox/styles.css';
|
|
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;
|
|
}
|
|
|
|
const ListOfEventsGallery: React.FC<EditEventFormProps> = ({ eventId }) => {
|
|
const router = useRouter();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
const [eventImages, setEventImages] = useState<GalleryImage[]>([]);
|
|
const latestImagesRef = useRef<GalleryImage[]>([]);
|
|
|
|
useEffect(() => {
|
|
latestImagesRef.current = eventImages;
|
|
}, [eventImages]);
|
|
|
|
useEffect(() => {
|
|
if (eventId) {
|
|
getEventGallery();
|
|
}
|
|
}, [eventId]);
|
|
|
|
const getEventGallery = async () => {
|
|
try {
|
|
const res = await axios.get(buildApiUrl(`event-images/event/${eventId}`));
|
|
const formatted: GalleryImage[] = res.data?.data?.map((img: any) => ({
|
|
id: img.id,
|
|
src: img.imageurl,
|
|
title: img?.title || '',
|
|
description: img?.description || '',
|
|
})) || [];
|
|
setEventImages(formatted);
|
|
} catch (error) {
|
|
console.log('error', error);
|
|
}
|
|
};
|
|
|
|
const handleReorder = (newList: GalleryImage[]) => {
|
|
setEventImages(newList);
|
|
};
|
|
|
|
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',
|
|
title: 'Are you sure?',
|
|
text: "You won't be able to revert this!",
|
|
showCancelButton: true,
|
|
confirmButtonText: 'Delete',
|
|
padding: '2em',
|
|
customClass: { popup: 'sweet-alerts' },
|
|
}).then(async (result) => {
|
|
if (result.isConfirmed) {
|
|
try {
|
|
await axios.delete(buildApiUrl(`event-images/${item.id}`));
|
|
Swal.fire({
|
|
title: 'Deleted!',
|
|
text: 'Your file has been deleted.',
|
|
icon: 'success',
|
|
customClass: { popup: 'sweet-alerts' },
|
|
});
|
|
getEventGallery();
|
|
} catch (error) {
|
|
Swal.fire({
|
|
title: 'Error!',
|
|
text: 'Failed to delete the file.',
|
|
icon: 'error',
|
|
customClass: { popup: 'sweet-alerts' },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="panel">
|
|
{/* 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-4 py-2 rounded text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
>
|
|
Upload Images
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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) => (
|
|
<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);
|
|
}}
|
|
>
|
|
{/* 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>
|
|
|
|
{/* 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)' } }}
|
|
open={isOpen}
|
|
close={() => setIsOpen(false)}
|
|
slides={eventImages}
|
|
index={photoIndex}
|
|
plugins={[Captions, Zoom]}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ListOfEventsGallery;
|