2 Commits

Author SHA1 Message Date
3ca72674af Fix header/footer placement on image upload step 2025-02-26 21:39:09 -05:00
c185d4e3ca More drag and drop tweaks 2025-02-26 20:53:28 -05:00

View File

@@ -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>