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 { 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">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event) => handleDragEnd(event, index)}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={getProductImages(index).map(img => img.id)}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
|
>
|
||||||
{getProductImages(index).map((image, imgIndex) => (
|
{getProductImages(index).map((image, imgIndex) => (
|
||||||
<div
|
<SortableImage
|
||||||
key={`${index}-${imgIndex}`}
|
key={image.id}
|
||||||
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0"
|
image={image}
|
||||||
>
|
productIndex={index}
|
||||||
{image.loading ? (
|
imgIndex={imgIndex}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden file input for backwards compatibility */}
|
{/* Hidden file input for backwards compatibility */}
|
||||||
|
|||||||
92
package-lock.json
generated
92
package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user