Make images rearrange-able with drag and drop
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useRsi } from "../../hooks/useRsi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Upload, Trash2, AlertCircle } from "lucide-react";
|
||||
import { Loader2, Upload, Trash2, AlertCircle, GripVertical } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -11,6 +11,23 @@ import config from "@/config";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
rectSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
type Props<T extends string> = {
|
||||
data: any[];
|
||||
@@ -31,6 +48,11 @@ type UnassignedImage = {
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
// Add a product ID type to handle the sortable state
|
||||
type ProductImageSortable = ProductImage & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const ImageUploadStep = <T extends string>({
|
||||
data,
|
||||
file,
|
||||
@@ -39,12 +61,70 @@ export const ImageUploadStep = <T extends string>({
|
||||
}: Props<T>) => {
|
||||
const { translations } = useRsi<T>();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [productImages, setProductImages] = useState<ProductImage[]>([]);
|
||||
const [productImages, setProductImages] = useState<ProductImageSortable[]>([]);
|
||||
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
|
||||
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
|
||||
const [processingBulk, setProcessingBulk] = useState(false);
|
||||
const [showUnassigned, setShowUnassigned] = useState(false);
|
||||
|
||||
// Set up sensors for drag and drop
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Handle drag end event to reorder images
|
||||
const handleDragEnd = (event: DragEndEvent, productIndex: number) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setProductImages(items => {
|
||||
// Filter to get only the images for this product
|
||||
const productFilteredItems = items.filter(item => item.productIndex === productIndex);
|
||||
|
||||
// Find the indices within this filtered list
|
||||
const oldIndex = productFilteredItems.findIndex(item => item.id === active.id);
|
||||
const newIndex = productFilteredItems.findIndex(item => item.id === over.id);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return items;
|
||||
|
||||
// Reorder the filtered items
|
||||
const newFilteredItems = arrayMove(productFilteredItems, oldIndex, newIndex);
|
||||
|
||||
// Create a new full list replacing the items for this product with the reordered ones
|
||||
const newItems = items.filter(item => item.productIndex !== productIndex);
|
||||
newItems.push(...newFilteredItems);
|
||||
|
||||
// Update the product data with the new image order
|
||||
updateProductImageOrder(productIndex, newFilteredItems);
|
||||
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Function to update product data with the new image order
|
||||
const updateProductImageOrder = (productIndex: number, orderedImages: ProductImageSortable[]) => {
|
||||
// Create a copy of the data
|
||||
const newData = [...data];
|
||||
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Get the ordered URLs
|
||||
const orderedUrls = orderedImages.map(img => img.imageUrl);
|
||||
|
||||
// Update the product with the ordered URLs
|
||||
product.image_url = orderedUrls.join(',');
|
||||
|
||||
// Update the data
|
||||
newData[productIndex] = product;
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Function to handle image upload
|
||||
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -53,7 +133,8 @@ export const ImageUploadStep = <T extends string>({
|
||||
const file = files[i];
|
||||
|
||||
// Add placeholder for this image
|
||||
const newImage: ProductImage = {
|
||||
const newImage: ProductImageSortable = {
|
||||
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
|
||||
productIndex,
|
||||
imageUrl: '',
|
||||
loading: true,
|
||||
@@ -509,7 +590,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<Select onValueChange={(value) => assignImageToProduct(index, parseInt(value))}>
|
||||
<SelectTrigger className="h-7 text-xs bg-white/10 border-0">
|
||||
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white">
|
||||
<SelectValue placeholder="Assign to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -523,10 +604,10 @@ export const ImageUploadStep = <T extends string>({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 bg-white/10"
|
||||
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white"
|
||||
onClick={() => removeUnassignedImage(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-white" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,6 +619,60 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
};
|
||||
|
||||
// Sortable Image component
|
||||
const SortableImage = ({ image, productIndex, imgIndex }: { image: ProductImageSortable, productIndex: number, imgIndex: number }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: image.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1 : 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{image.loading ? (
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin mb-1" />
|
||||
<span className="text-xs text-center truncate w-full">{image.fileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={getFullImageUrl(image.imageUrl)}
|
||||
alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false} // Prevent browser's native image drag
|
||||
/>
|
||||
<button
|
||||
className="absolute top-1 right-1 bg-black/60 rounded-full p-0.5 text-white z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering drag listeners
|
||||
removeImage(productImages.findIndex(img => img.id === image.id));
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4">
|
||||
@@ -573,37 +708,27 @@ export const ImageUploadStep = <T extends string>({
|
||||
{/* Dropzone for image upload always on the left */}
|
||||
<ImageDropzone productIndex={index} />
|
||||
|
||||
{/* Images appear to the right of the dropzone in a scrollable container */}
|
||||
{/* Images appear to the right of the dropzone in a sortable container */}
|
||||
<div className="flex flex-wrap gap-2 overflow-x-auto flex-1">
|
||||
{getProductImages(index).map((image, imgIndex) => (
|
||||
<div
|
||||
key={`${index}-${imgIndex}`}
|
||||
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0"
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => handleDragEnd(event, index)}
|
||||
>
|
||||
<SortableContext
|
||||
items={getProductImages(index).map(img => img.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
{image.loading ? (
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin mb-1" />
|
||||
<span className="text-xs text-center truncate w-full">{image.fileName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={getFullImageUrl(image.imageUrl)}
|
||||
alt={`Product ${index + 1} - Image ${imgIndex + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
className="absolute top-1 right-1 bg-black/60 rounded-full p-0.5 text-white"
|
||||
onClick={() => removeImage(productImages.findIndex(img =>
|
||||
img.productIndex === index && img.imageUrl === image.imageUrl
|
||||
))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{getProductImages(index).map((image, imgIndex) => (
|
||||
<SortableImage
|
||||
key={image.id}
|
||||
image={image}
|
||||
productIndex={index}
|
||||
imgIndex={imgIndex}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for backwards compatibility */}
|
||||
|
||||
Reference in New Issue
Block a user