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 { useCallback, useState, useRef, useEffect } from "react";
import { useRsi } from "../../hooks/useRsi"; import { useRsi } from "../../hooks/useRsi";
import { Button } from "@/components/ui/button"; 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 { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -11,6 +11,23 @@ import config from "@/config";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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> = { type Props<T extends string> = {
data: any[]; data: any[];
@@ -31,6 +48,11 @@ type UnassignedImage = {
previewUrl: string; previewUrl: string;
} }
// Add a product ID type to handle the sortable state
type ProductImageSortable = ProductImage & {
id: string;
};
export const ImageUploadStep = <T extends string>({ export const ImageUploadStep = <T extends string>({
data, data,
file, file,
@@ -39,12 +61,70 @@ export const ImageUploadStep = <T extends string>({
}: Props<T>) => { }: Props<T>) => {
const { translations } = useRsi<T>(); const { translations } = useRsi<T>();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [productImages, setProductImages] = useState<ProductImage[]>([]); const [productImages, setProductImages] = useState<ProductImageSortable[]>([]);
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]); const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
const [processingBulk, setProcessingBulk] = useState(false); const [processingBulk, setProcessingBulk] = useState(false);
const [showUnassigned, setShowUnassigned] = 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 // Function to handle image upload
const handleImageUpload = async (files: FileList | File[], productIndex: number) => { const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@@ -53,7 +133,8 @@ export const ImageUploadStep = <T extends string>({
const file = files[i]; const file = files[i];
// Add placeholder for this image // Add placeholder for this image
const newImage: ProductImage = { const newImage: ProductImageSortable = {
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
productIndex, productIndex,
imageUrl: '', imageUrl: '',
loading: true, 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> <p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Select onValueChange={(value) => assignImageToProduct(index, parseInt(value))}> <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..." /> <SelectValue placeholder="Assign to..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -523,10 +604,10 @@ export const ImageUploadStep = <T extends string>({
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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)} onClick={() => removeUnassignedImage(index)}
> >
<Trash2 className="h-3.5 w-3.5 text-white" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="p-4"> <div className="p-4">
@@ -573,37 +708,27 @@ export const ImageUploadStep = <T extends string>({
{/* Dropzone for image upload always on the left */} {/* Dropzone for image upload always on the left */}
<ImageDropzone productIndex={index} /> <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"> <div className="flex flex-wrap gap-2 overflow-x-auto flex-1">
{getProductImages(index).map((image, imgIndex) => ( <DndContext
<div sensors={sensors}
key={`${index}-${imgIndex}`} collisionDetection={closestCenter}
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0" onDragEnd={(event) => handleDragEnd(event, index)}
>
<SortableContext
items={getProductImages(index).map(img => img.id)}
strategy={rectSortingStrategy}
> >
{image.loading ? ( {getProductImages(index).map((image, imgIndex) => (
<div className="flex flex-col items-center justify-center p-2"> <SortableImage
<Loader2 className="h-5 w-5 animate-spin mb-1" /> key={image.id}
<span className="text-xs text-center truncate w-full">{image.fileName}</span> image={image}
</div> productIndex={index}
) : ( imgIndex={imgIndex}
<> />
<img ))}
src={getFullImageUrl(image.imageUrl)} </SortableContext>
alt={`Product ${index + 1} - Image ${imgIndex + 1}`} </DndContext>
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>
))}
</div> </div>
{/* Hidden file input for backwards compatibility */} {/* Hidden file input for backwards compatibility */}

92
package-lock.json generated
View File

@@ -5,6 +5,9 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"shadcn": "^1.0.0" "shadcn": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -12,6 +15,59 @@
"ts-essentials": "^10.0.4" "ts-essentials": "^10.0.4"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@types/diff": { "node_modules/@types/diff": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.1.tgz",
@@ -19,6 +75,36 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT",
"peer": true
},
"node_modules/shadcn": { "node_modules/shadcn": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz", "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz",
@@ -39,6 +125,12 @@
"optional": true "optional": true
} }
} }
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
} }
} }
} }

View File

@@ -1,5 +1,8 @@
{ {
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"shadcn": "^1.0.0" "shadcn": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {