Make images rearrange-able with drag and drop

This commit is contained in:
2025-02-26 16:31:56 -05:00
parent 8141fafb34
commit 41f7f33746
3 changed files with 255 additions and 35 deletions

View File

@@ -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 */}