Compare commits
2 Commits
2d62cac5f7
...
3ca72674af
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ca72674af | |||
| c185d4e3ca |
@@ -3,8 +3,6 @@ import { useRsi } from "../../hooks/useRsi";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X } from "lucide-react";
|
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
@@ -13,7 +11,6 @@ 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 {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
@@ -23,10 +20,7 @@ import {
|
|||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
pointerWithin,
|
pointerWithin,
|
||||||
rectIntersection,
|
rectIntersection,
|
||||||
getFirstCollision,
|
|
||||||
useDndMonitor,
|
|
||||||
DragMoveEvent,
|
DragMoveEvent,
|
||||||
closestCorners,
|
|
||||||
CollisionDetection,
|
CollisionDetection,
|
||||||
useDroppable
|
useDroppable
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
@@ -35,7 +29,6 @@ import {
|
|||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
useSortable,
|
useSortable,
|
||||||
rectSortingStrategy,
|
|
||||||
horizontalListSortingStrategy
|
horizontalListSortingStrategy
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
@@ -45,7 +38,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
type Props<T extends string> = {
|
type Props<T extends string = string> = {
|
||||||
data: any[];
|
data: any[];
|
||||||
file: File;
|
file: File;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -88,9 +81,10 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Set up sensors for drag and drop with enhanced configuration
|
// Set up sensors for drag and drop with enhanced configuration
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
// Make it responsive with minimal constraints
|
// Make it responsive with less restrictive constraints
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 3, // Reduced distance for more responsive drag
|
distance: 1, // Reduced distance for more responsive drag
|
||||||
|
delay: 0, // No delay
|
||||||
tolerance: 5
|
tolerance: 5
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -104,73 +98,15 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
|
|
||||||
// Custom collision detection algorithm that prioritizes product containers
|
// Custom collision detection algorithm that prioritizes product containers
|
||||||
const customCollisionDetection: CollisionDetection = (args) => {
|
const customCollisionDetection: CollisionDetection = (args) => {
|
||||||
const { droppableContainers, active, pointerCoordinates } = args;
|
// Use the built-in pointerWithin algorithm first for better performance
|
||||||
|
|
||||||
if (!pointerCoordinates) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the active container (product index)
|
|
||||||
const activeContainer = findContainer(active.id.toString());
|
|
||||||
|
|
||||||
// Get all collisions
|
|
||||||
const pointerCollisions = pointerWithin(args);
|
const pointerCollisions = pointerWithin(args);
|
||||||
const rectCollisions = rectIntersection(args);
|
|
||||||
|
|
||||||
// Combine collision methods for more reliable detection
|
if (pointerCollisions.length > 0) {
|
||||||
const allCollisions = [...pointerCollisions, ...rectCollisions];
|
return pointerCollisions;
|
||||||
|
|
||||||
// Check for image collisions first - for reordering within the same container
|
|
||||||
const imageCollisions = allCollisions.filter(collision => {
|
|
||||||
const collisionId = collision.id.toString();
|
|
||||||
// Only detect other images, not the active image
|
|
||||||
if (collisionId === active.id.toString()) return false;
|
|
||||||
|
|
||||||
// Check if it's an image by looking for it in productImages
|
|
||||||
return productImages.some(img => img.id === collisionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we have image collisions within the same container, prioritize those for reordering
|
|
||||||
if (activeContainer && imageCollisions.length > 0) {
|
|
||||||
const sameContainerCollisions = imageCollisions.filter(collision => {
|
|
||||||
const image = productImages.find(img => img.id === collision.id);
|
|
||||||
return image && image.productIndex.toString() === activeContainer;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sameContainerCollisions.length > 0) {
|
|
||||||
return [sameContainerCollisions[0]]; // Return the first image collision in the same container
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no image collisions in the same container, check for product container collisions
|
// Fall back to rectIntersection if no pointer collisions
|
||||||
const productContainerCollisions = allCollisions.filter(
|
return rectIntersection(args);
|
||||||
collision => typeof collision.id === 'string' && collision.id.toString().startsWith('product-')
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the active container is different from container collisions, prioritize those
|
|
||||||
if (activeContainer && productContainerCollisions.length > 0) {
|
|
||||||
const differentContainerCollisions = productContainerCollisions.filter(collision => {
|
|
||||||
const containerIndex = collision.id.toString().split('-')[1];
|
|
||||||
return containerIndex !== activeContainer;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (differentContainerCollisions.length > 0) {
|
|
||||||
return [differentContainerCollisions[0]];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have any product container collisions, use those
|
|
||||||
if (productContainerCollisions.length > 0) {
|
|
||||||
return [productContainerCollisions[0]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally, check images in different containers
|
|
||||||
if (imageCollisions.length > 0) {
|
|
||||||
return [imageCollisions[0]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to all collisions
|
|
||||||
return allCollisions.length > 0 ? [allCollisions[0]] : [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle drag start to set active image and prevent default behavior
|
// Handle drag start to set active image and prevent default behavior
|
||||||
@@ -186,14 +122,13 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
|
|
||||||
// Handle drag over to track which product container is being hovered
|
// Handle drag over to track which product container is being hovered
|
||||||
const handleDragOver = (event: DragMoveEvent) => {
|
const handleDragOver = (event: DragMoveEvent) => {
|
||||||
const { active, over } = event;
|
const { over } = event;
|
||||||
|
|
||||||
if (!over) {
|
if (!over) {
|
||||||
setActiveDroppableId(null);
|
setActiveDroppableId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeContainer = findContainer(active.id.toString());
|
|
||||||
let overContainer = null;
|
let overContainer = null;
|
||||||
|
|
||||||
// Check if we're over a product container directly
|
// Check if we're over a product container directly
|
||||||
@@ -255,21 +190,12 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Check if the container has images
|
// Check if the container has images
|
||||||
const hasImages = getProductImages(index).length > 0;
|
const hasImages = getProductImages(index).length > 0;
|
||||||
|
|
||||||
// Add stronger attributes for empty containers to ensure they stand out as drop targets
|
// Set data-empty attribute for tracking purposes
|
||||||
if (!hasImages) {
|
container.setAttribute('data-empty', hasImages ? 'false' : 'true');
|
||||||
container.setAttribute('data-empty', 'true');
|
|
||||||
// Setting tabindex makes the element focusable which can help with accessibility
|
// Ensure the container has sufficient size to be a drop target
|
||||||
container.setAttribute('tabindex', '0');
|
if (container.offsetHeight < 100) {
|
||||||
|
container.style.minHeight = '100px';
|
||||||
// Add ARIA attributes to improve accessibility and recognition
|
|
||||||
container.setAttribute('aria-label', `Empty drop zone for product ${index + 1}`);
|
|
||||||
|
|
||||||
// Ensure the empty container has sufficient size to be a drop target
|
|
||||||
if (container.offsetHeight < 100) {
|
|
||||||
container.style.minHeight = '100px';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
container.setAttribute('data-empty', 'false');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -285,20 +211,16 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Define handlers for native browser drag events
|
// Define handlers for native browser drag events
|
||||||
const handleNativeDragOver = (e: DragEvent) => {
|
const handleNativeDragOver = (e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (getProductImages(index).length === 0) {
|
// Highlight all containers during dragover
|
||||||
// Highlight empty containers during dragover
|
container.classList.add('border-primary', 'bg-primary/5');
|
||||||
container.classList.add('border-primary', 'bg-primary/5');
|
setActiveDroppableId(`product-${index}`);
|
||||||
setActiveDroppableId(`product-${index}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNativeDragLeave = (e: DragEvent) => {
|
const handleNativeDragLeave = () => {
|
||||||
if (getProductImages(index).length === 0) {
|
// Remove highlight when drag leaves
|
||||||
// Remove highlight when drag leaves
|
container.classList.remove('border-primary', 'bg-primary/5');
|
||||||
container.classList.remove('border-primary', 'bg-primary/5');
|
if (activeDroppableId === `product-${index}`) {
|
||||||
if (activeDroppableId === `product-${index}`) {
|
setActiveDroppableId(null);
|
||||||
setActiveDroppableId(null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,11 +228,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
container.addEventListener('dragover', handleNativeDragOver);
|
container.addEventListener('dragover', handleNativeDragOver);
|
||||||
container.addEventListener('dragleave', handleNativeDragLeave);
|
container.addEventListener('dragleave', handleNativeDragLeave);
|
||||||
|
|
||||||
// Log this for debugging
|
|
||||||
console.log(`Added native drag handlers to product-${index}`, {
|
|
||||||
isEmpty: getProductImages(index).length === 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return cleanup function
|
// Return cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('dragover', handleNativeDragOver);
|
container.removeEventListener('dragover', handleNativeDragOver);
|
||||||
@@ -318,7 +235,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [data, productImages]); // Re-run when data or productImages change
|
}, [data, productImages, activeDroppableId]); // Re-run when data or productImages change
|
||||||
|
|
||||||
// Function to remove an image URL from a product
|
// Function to remove an image URL from a product
|
||||||
const removeImageFromProduct = (productIndex: number, imageUrl: string) => {
|
const removeImageFromProduct = (productIndex: number, imageUrl: string) => {
|
||||||
@@ -489,7 +406,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
newItems.push(...newFilteredItems);
|
newItems.push(...newFilteredItems);
|
||||||
|
|
||||||
// Update the product data with the new image order
|
// Update the product data with the new image order
|
||||||
const updatedData = updateProductImageOrder(sourceProductIndex, newFilteredItems);
|
|
||||||
|
|
||||||
return newItems;
|
return newItems;
|
||||||
}
|
}
|
||||||
@@ -511,7 +427,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
newItems.push(...newFilteredItems);
|
newItems.push(...newFilteredItems);
|
||||||
|
|
||||||
// Update the product data with the new image order
|
// Update the product data with the new image order
|
||||||
const updatedData = updateProductImageOrder(sourceProductIndex, newFilteredItems);
|
|
||||||
|
|
||||||
return newItems;
|
return newItems;
|
||||||
});
|
});
|
||||||
@@ -559,24 +474,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to update product data with the new image order
|
// 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) => {
|
||||||
@@ -626,7 +523,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Update the product data with the new image URL
|
// Update the product data with the new image URL
|
||||||
const updatedData = addImageToProduct(productIndex, result.imageUrl);
|
|
||||||
|
|
||||||
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -853,7 +749,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
|
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
|
||||||
|
|
||||||
// Remove the image URL from the product data
|
// Remove the image URL from the product data
|
||||||
const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl);
|
|
||||||
|
|
||||||
toast.success('Image removed successfully');
|
toast.success('Image removed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -958,7 +853,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md h-24 w-24 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/70 transition-colors shrink-0",
|
"border-2 border-dashed border-secondary-foreground/30 bg-muted/90 rounded-md h-24 w-24 flex flex-col items-center justify-center self-center cursor-pointer hover:bg-muted/70 transition-colors shrink-0",
|
||||||
isDragActive && "border-primary bg-muted"
|
isDragActive && "border-primary bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -984,7 +879,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<img
|
<img
|
||||||
src={image.previewUrl}
|
src={image.previewUrl}
|
||||||
alt={`Unassigned image ${index + 1}`}
|
alt={`Unassigned image ${index + 1}`}
|
||||||
className="h-28 w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2">
|
<div className="absolute bottom-0 left-0 right-0 bg-black/60 p-2">
|
||||||
<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>
|
||||||
@@ -1018,7 +913,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="absolute top-1 left-1 bg-black/60 rounded-full p-1.5 text-white z-10 hover:bg-black/80"
|
className="absolute top-1 left-1 bg-black/60 rounded-full p-1 text-white z-10 hover:bg-black/80"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1093,57 +988,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add the ZoomedImage component to show a larger version of the image
|
// Add the ZoomedImage component to show a larger version of the image
|
||||||
const ZoomedImage = ({ imageUrl, alt }: { imageUrl: string; alt: string }) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
className="absolute bottom-1 left-1 bg-black/60 rounded-full p-1.5 text-white z-10 hover:bg-black/80"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation(); // Prevent triggering drag listeners
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
e.stopPropagation(); // Prevent drag from starting
|
|
||||||
}}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.stopPropagation(); // Prevent drag from starting
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation(); // Prevent drag from starting on touch
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[650px] max-h-[90vh] p-6 bg-background/95 backdrop-blur-sm border border-border rounded-lg shadow-2xl">
|
|
||||||
<div className="relative flex flex-col items-center justify-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute -top-2 -right-2 bg-black/60 text-white hover:bg-black/80 rounded-full z-10 h-8 w-8"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt={alt}
|
|
||||||
className="max-h-[70vh] max-w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
|
||||||
{alt || "Product Image"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sortable Image component with enhanced drag prevention
|
// Sortable Image component with enhanced drag prevention
|
||||||
const SortableImage = ({ image, productIndex, imgIndex }: { image: ProductImageSortable, productIndex: number, imgIndex: number }) => {
|
const SortableImage = ({ image, productIndex, imgIndex }: { image: ProductImageSortable, productIndex: number, imgIndex: number }) => {
|
||||||
@@ -1166,17 +1010,19 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a new style object with fixed dimensions to prevent distortion
|
// Create a new style object with fixed dimensions to prevent distortion
|
||||||
const style = {
|
const style: React.CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
zIndex: isDragging ? 10 : 0, // Higher z-index when dragging
|
zIndex: isDragging ? 999 : 1, // Higher z-index when dragging
|
||||||
touchAction: 'none' as 'none', // Prevent touch scrolling during drag
|
touchAction: 'none', // Prevent touch scrolling during drag
|
||||||
|
userSelect: 'none', // Prevent text selection during drag
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
width: '96px',
|
width: '96px',
|
||||||
height: '96px',
|
height: '96px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
position: 'relative' as 'relative',
|
position: 'relative',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a ref for the buttons to exclude them from drag listeners
|
// Create a ref for the buttons to exclude them from drag listeners
|
||||||
@@ -1187,9 +1033,14 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className="relative border rounded-md overflow-hidden flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing select-none no-native-drag"
|
className="relative border rounded-md overflow-hidden flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing select-none no-native-drag group hover:ring-2 hover:ring-primary/30 transition-all"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
// This ensures the native drag doesn't interfere
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{image.loading ? (
|
{image.loading ? (
|
||||||
<div className="flex flex-col items-center justify-center p-2">
|
<div className="flex flex-col items-center justify-center p-2">
|
||||||
@@ -1200,13 +1051,17 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
src={getFullImageUrl(image.imageUrl)}
|
src={getFullImageUrl(image.imageUrl)}
|
||||||
alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
alt={`${data[productIndex].name || `Product #${productIndex + 1}`} - Image ${imgIndex + 1}`}
|
||||||
className="h-full w-full object-cover select-none no-native-drag"
|
className="h-full w-full object-cover select-none no-native-drag"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
|
||||||
|
<div className="absolute right-0 top-0 p-1 opacity-0 group-hover:opacity-90 transition-opacity">
|
||||||
|
<GripVertical className="h-3 w-3 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
ref={deleteButtonRef}
|
ref={deleteButtonRef}
|
||||||
className="absolute top-1 right-1 bg-black/60 rounded-full p-1 text-white z-10 hover:bg-black/80"
|
className="absolute opacity-0 group-hover:opacity-100 transition-opacity duration-200 top-1 right-1 bg-black/40 hover:bg-black/70 hover:scale-110 rounded-full p-1 text-white z-10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation(); // Prevent triggering drag listeners
|
e.stopPropagation(); // Prevent triggering drag listeners
|
||||||
@@ -1222,7 +1077,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
e.stopPropagation(); // Prevent drag from starting on touch
|
e.stopPropagation(); // Prevent drag from starting on touch
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Fix zoom button with proper state management */}
|
{/* Fix zoom button with proper state management */}
|
||||||
@@ -1230,7 +1085,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<button
|
<button
|
||||||
ref={zoomButtonRef}
|
ref={zoomButtonRef}
|
||||||
className="absolute bottom-1 left-1 bg-black/60 rounded-full p-1 text-white z-10 hover:bg-black/80"
|
className="absolute opacity-0 group-hover:opacity-100 transition-opacity duration-200 bottom-1 left-1 bg-black/40 hover:bg-black/70 hover:scale-110 rounded-full p-1 text-white z-10"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation(); // Prevent triggering drag listeners
|
e.stopPropagation(); // Prevent triggering drag listeners
|
||||||
@@ -1262,12 +1117,12 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
||||||
<img
|
<img
|
||||||
src={getFullImageUrl(image.imageUrl)}
|
src={getFullImageUrl(image.imageUrl)}
|
||||||
alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
alt={`${data[productIndex].name || `Product #${productIndex + 1}`} - Image ${imgIndex + 1}`}
|
||||||
className="max-h-[70vh] max-w-full object-contain"
|
className="max-h-[70vh] max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||||
{`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
{`${data[productIndex].name || `Product #${productIndex + 1}`} - Image ${imgIndex + 1}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1282,24 +1137,19 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
const getProductContainerClasses = (index: number) => {
|
const getProductContainerClasses = (index: number) => {
|
||||||
const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
|
const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
|
||||||
const isActiveDropTarget = activeDroppableId === `product-${index}`;
|
const isActiveDropTarget = activeDroppableId === `product-${index}`;
|
||||||
const hasImages = getProductImages(index).length > 0;
|
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
"flex-1 min-h-[6rem] rounded-md p-2 transition-all",
|
"flex-1 min-h-[6rem] rounded-md p-2 transition-all",
|
||||||
// Always add a border for empty containers to make them visible as drop targets
|
// Only show borders during active drag operations
|
||||||
!hasImages && "border-2 border-dashed border-secondary-foreground/30",
|
|
||||||
// Active drop target styling
|
|
||||||
isValidDropTarget && isActiveDropTarget
|
isValidDropTarget && isActiveDropTarget
|
||||||
? "border-2 border-dashed border-primary bg-primary/10"
|
? "border-2 border-dashed border-primary bg-primary/10"
|
||||||
: isValidDropTarget && !hasImages
|
: isValidDropTarget
|
||||||
? "border-2 border-dashed border-muted-foreground/40 bg-muted/20"
|
? "border border-dashed border-muted-foreground/30"
|
||||||
: isValidDropTarget
|
: ""
|
||||||
? "border border-dashed border-muted-foreground/30"
|
|
||||||
: ""
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add a DroppableContainer component for empty product containers
|
// Add a DroppableContainer component for product containers
|
||||||
const DroppableContainer = ({ id, children, isEmpty }: { id: string; children: React.ReactNode; isEmpty: boolean }) => {
|
const DroppableContainer = ({ id, children, isEmpty }: { id: string; children: React.ReactNode; isEmpty: boolean }) => {
|
||||||
const { setNodeRef } = useDroppable({
|
const { setNodeRef } = useDroppable({
|
||||||
id,
|
id,
|
||||||
@@ -1315,7 +1165,8 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
id={id}
|
id={id}
|
||||||
data-droppable="true"
|
data-droppable="true"
|
||||||
data-empty={isEmpty ? "true" : "false"}
|
data-empty={isEmpty ? "true" : "false"}
|
||||||
className={`w-full h-full ${!isEmpty ? "flex flex-row flex-wrap gap-2" : ""}`}
|
className="w-full h-full flex flex-row flex-wrap gap-2"
|
||||||
|
style={{ minHeight: '100px' }} // Ensure minimum height for empty containers
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -1323,168 +1174,168 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
||||||
<div className="p-4">
|
{/* Header - fixed at top */}
|
||||||
<h2 className="text-2xl font-semibold mb-2">Add Product Images</h2>
|
<div className="px-8 py-6 bg-background shrink-0">
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<h2 className="text-2xl font-semibold">Add Product Images</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Upload images for each product. Drag images to reorder them or move them between products.
|
Upload images for each product. Drag images to reorder them or move them between products.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 mb-4">
|
{/* Content area - only this part scrolls */}
|
||||||
<GenericDropzone />
|
<div className="flex-1 overflow-hidden">
|
||||||
</div>
|
<div className="h-full flex flex-col overflow-auto">
|
||||||
|
<div className="px-8 py-4 shrink-0">
|
||||||
<UnassignedImagesSection />
|
<GenericDropzone />
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={customCollisionDetection}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
autoScroll={{
|
|
||||||
threshold: {
|
|
||||||
x: 0,
|
|
||||||
y: 0.2, // Start scrolling when dragging near the edges
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Add helper invisible div to handle global styles during drag */}
|
|
||||||
{activeId && (
|
|
||||||
<style dangerouslySetInnerHTML={{
|
|
||||||
__html: `
|
|
||||||
body {
|
|
||||||
overflow-anchor: none;
|
|
||||||
}
|
|
||||||
img {
|
|
||||||
-webkit-user-drag: none !important;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data.map((product: any, index: number) => (
|
|
||||||
<Card
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
"p-3 transition-colors",
|
|
||||||
activeDroppableId === `product-${index}` && activeId &&
|
|
||||||
findContainer(activeId) !== index.toString() &&
|
|
||||||
"ring-2 ring-primary bg-primary/5"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
|
||||||
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
|
||||||
<div className="text-xs lg:text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium">UPC:</span> {product.upc || 'N/A'} |
|
|
||||||
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
{/* Dropzone for image upload always on the left */}
|
|
||||||
<ImageDropzone productIndex={index} />
|
|
||||||
|
|
||||||
{/* Images appear to the right of the dropzone in a sortable container */}
|
|
||||||
<div
|
|
||||||
className={getProductContainerClasses(index)}
|
|
||||||
style={{
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
touchAction: 'none',
|
|
||||||
minHeight: getProductImages(index).length === 0 ? '100px' : 'auto'
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
// This is a native event handler to ensure the browser recognizes the drop zone
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (getProductImages(index).length === 0) {
|
|
||||||
setActiveDroppableId(`product-${index}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DroppableContainer
|
|
||||||
id={`product-${index}`}
|
|
||||||
isEmpty={getProductImages(index).length === 0}
|
|
||||||
>
|
|
||||||
{getProductImages(index).length > 0 ? (
|
|
||||||
<SortableContext
|
|
||||||
items={getProductImages(index).map(img => img.id)}
|
|
||||||
strategy={horizontalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{getProductImages(index).map((image, imgIndex) => (
|
|
||||||
<SortableImage
|
|
||||||
key={image.id}
|
|
||||||
image={image}
|
|
||||||
productIndex={index}
|
|
||||||
imgIndex={imgIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm py-8">
|
|
||||||
Drop images here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DroppableContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden file input for backwards compatibility */}
|
|
||||||
<Input
|
|
||||||
ref={el => fileInputRefs.current[index] = el}
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
onChange={(e) => e.target.files && handleImageUpload(e.target.files, index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag overlay for showing the dragged image */}
|
<div className="px-8 py-2 shrink-0">
|
||||||
<DragOverlay adjustScale={false} zIndex={1000} dropAnimation={null}>
|
<UnassignedImagesSection />
|
||||||
{activeId && activeImage && (
|
</div>
|
||||||
<div
|
|
||||||
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center opacity-90 cursor-grabbing shadow-xl no-native-drag"
|
{/* Scrollable product cards */}
|
||||||
style={{
|
<div className="px-8 py-2 flex-1">
|
||||||
width: "96px",
|
<DndContext
|
||||||
height: "96px",
|
sensors={sensors}
|
||||||
transform: "none",
|
collisionDetection={customCollisionDetection}
|
||||||
willChange: "transform"
|
onDragStart={handleDragStart}
|
||||||
}}
|
onDragOver={handleDragOver}
|
||||||
>
|
onDragEnd={handleDragEnd}
|
||||||
<img
|
autoScroll={{
|
||||||
src={getFullImageUrl(activeImage.imageUrl)}
|
threshold: {
|
||||||
alt="Dragging image"
|
x: 0,
|
||||||
className="h-full w-full object-cover select-none no-native-drag"
|
y: 0.2,
|
||||||
draggable={false}
|
}
|
||||||
/>
|
}}
|
||||||
<div className="absolute inset-0 bg-primary/10 border-2 border-primary pointer-events-none"></div>
|
>
|
||||||
|
{activeId && (
|
||||||
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
body {
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
-webkit-user-drag: none !important;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.map((product: any, index: number) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"p-3 transition-colors",
|
||||||
|
activeDroppableId === `product-${index}` && activeId &&
|
||||||
|
findContainer(activeId) !== index.toString() &&
|
||||||
|
"ring-2 ring-primary bg-primary/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
||||||
|
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
||||||
|
<div className="text-xs lg:text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">UPC:</span> {product.upc || 'N/A'} |
|
||||||
|
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ImageDropzone productIndex={index} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={getProductContainerClasses(index)}
|
||||||
|
style={{
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
touchAction: 'none',
|
||||||
|
minHeight: '100px'
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setActiveDroppableId(`product-${index}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DroppableContainer
|
||||||
|
id={`product-${index}`}
|
||||||
|
isEmpty={getProductImages(index).length === 0}
|
||||||
|
>
|
||||||
|
{getProductImages(index).length > 0 ? (
|
||||||
|
<SortableContext
|
||||||
|
items={getProductImages(index).map(img => img.id)}
|
||||||
|
strategy={horizontalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{getProductImages(index).map((image, imgIndex) => (
|
||||||
|
<SortableImage
|
||||||
|
key={image.id}
|
||||||
|
image={image}
|
||||||
|
productIndex={index}
|
||||||
|
imgIndex={imgIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full" data-empty-placeholder="true"></div>
|
||||||
|
)}
|
||||||
|
</DroppableContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
ref={el => fileInputRefs.current[index] = el}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => e.target.files && handleImageUpload(e.target.files, index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</DragOverlay>
|
<DragOverlay adjustScale={false} zIndex={1000} dropAnimation={null}>
|
||||||
</DndContext>
|
{activeId && activeImage && (
|
||||||
</ScrollArea>
|
<div
|
||||||
|
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center opacity-90 cursor-grabbing shadow-xl no-native-drag"
|
||||||
|
style={{
|
||||||
|
width: "96px",
|
||||||
|
height: "96px",
|
||||||
|
transform: "none",
|
||||||
|
willChange: "transform"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getFullImageUrl(activeImage.imageUrl)}
|
||||||
|
alt="Dragging image"
|
||||||
|
className="h-full w-full object-cover select-none no-native-drag"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-primary/10 border-2 border-primary pointer-events-none"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
{/* Footer - fixed at bottom */}
|
||||||
|
<div className="flex items-center justify-between border-t bg-muted px-8 py-4 mt-1 shrink-0">
|
||||||
<div className="p-4 flex justify-between">
|
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleSubmit} disabled={isSubmitting || unassignedImages.length > 0}>
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || unassignedImages.length > 0}
|
||||||
|
>
|
||||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{unassignedImages.length > 0 ? 'Assign all images first' : 'Submit'}
|
{unassignedImages.length > 0 ? 'Assign all images first' : 'Submit'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user