Drag between products fix
This commit is contained in:
@@ -27,7 +27,8 @@ import {
|
|||||||
useDndMonitor,
|
useDndMonitor,
|
||||||
DragMoveEvent,
|
DragMoveEvent,
|
||||||
closestCorners,
|
closestCorners,
|
||||||
CollisionDetection
|
CollisionDetection,
|
||||||
|
useDroppable
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
@@ -68,26 +69,6 @@ type ProductImageSortable = ProductImage & {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom collision detection algorithm that combines multiple strategies
|
|
||||||
const customCollisionDetection: CollisionDetection = (args) => {
|
|
||||||
// First, try pointer intersection
|
|
||||||
const pointerCollisions = pointerWithin(args);
|
|
||||||
|
|
||||||
if (pointerCollisions.length > 0) {
|
|
||||||
return pointerCollisions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no pointer collisions, try rect intersection
|
|
||||||
const rectCollisions = rectIntersection(args);
|
|
||||||
|
|
||||||
if (rectCollisions.length > 0) {
|
|
||||||
return rectCollisions;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still no collisions, use closest corners
|
|
||||||
return closestCorners(args);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ImageUploadStep = <T extends string>({
|
export const ImageUploadStep = <T extends string>({
|
||||||
data,
|
data,
|
||||||
file,
|
file,
|
||||||
@@ -121,6 +102,77 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Track which product container is being hovered over
|
// Track which product container is being hovered over
|
||||||
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(null);
|
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Custom collision detection algorithm that prioritizes product containers
|
||||||
|
const customCollisionDetection: CollisionDetection = (args) => {
|
||||||
|
const { droppableContainers, active, pointerCoordinates } = args;
|
||||||
|
|
||||||
|
if (!pointerCoordinates) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the active container (product index)
|
||||||
|
const activeContainer = findContainer(active.id.toString());
|
||||||
|
|
||||||
|
// Get all collisions
|
||||||
|
const pointerCollisions = pointerWithin(args);
|
||||||
|
const rectCollisions = rectIntersection(args);
|
||||||
|
|
||||||
|
// Combine collision methods for more reliable detection
|
||||||
|
const allCollisions = [...pointerCollisions, ...rectCollisions];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const productContainerCollisions = allCollisions.filter(
|
||||||
|
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
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
const { active } = event;
|
const { active } = event;
|
||||||
@@ -134,21 +186,30 @@ 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 { over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over) {
|
if (!over) {
|
||||||
setActiveDroppableId(null);
|
setActiveDroppableId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're over a product container
|
const activeContainer = findContainer(active.id.toString());
|
||||||
if (typeof over.id === 'string' && over.id.startsWith('product-')) {
|
let overContainer = null;
|
||||||
setActiveDroppableId(over.id);
|
|
||||||
} else {
|
// Check if we're over a product container directly
|
||||||
// We might be over another image, so get its product container
|
if (typeof over.id === 'string' && over.id.toString().startsWith('product-')) {
|
||||||
|
overContainer = over.id.toString();
|
||||||
|
setActiveDroppableId(overContainer);
|
||||||
|
|
||||||
|
// Log the hover state for debugging
|
||||||
|
console.log('Hovering over product container:', overContainer);
|
||||||
|
}
|
||||||
|
// Otherwise check if we're over another image
|
||||||
|
else {
|
||||||
const overImage = productImages.find(img => img.id === over.id);
|
const overImage = productImages.find(img => img.id === over.id);
|
||||||
if (overImage) {
|
if (overImage) {
|
||||||
setActiveDroppableId(`product-${overImage.productIndex}`);
|
overContainer = `product-${overImage.productIndex}`;
|
||||||
|
setActiveDroppableId(overContainer);
|
||||||
} else {
|
} else {
|
||||||
setActiveDroppableId(null);
|
setActiveDroppableId(null);
|
||||||
}
|
}
|
||||||
@@ -171,12 +232,94 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
}, [activeId]);
|
}, [activeId]);
|
||||||
|
|
||||||
// Function to find the container (productIndex) an image belongs to
|
// Function to find container (productIndex) an image belongs to
|
||||||
const findContainer = (id: string) => {
|
const findContainer = (id: string) => {
|
||||||
const image = productImages.find(img => img.id === id);
|
const image = productImages.find(img => img.id === id);
|
||||||
return image ? image.productIndex.toString() : null;
|
return image ? image.productIndex.toString() : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to get images for a specific product
|
||||||
|
const getProductImages = (productIndex: number) => {
|
||||||
|
return productImages.filter(img => img.productIndex === productIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add product IDs to the valid droppable elements
|
||||||
|
useEffect(() => {
|
||||||
|
// Add data-droppable attributes to make product containers easier to identify
|
||||||
|
data.forEach((_, index) => {
|
||||||
|
const container = document.getElementById(`product-${index}`);
|
||||||
|
if (container) {
|
||||||
|
container.setAttribute('data-droppable', 'true');
|
||||||
|
container.setAttribute('aria-dropeffect', 'move');
|
||||||
|
|
||||||
|
// Check if the container has images
|
||||||
|
const hasImages = getProductImages(index).length > 0;
|
||||||
|
|
||||||
|
// Add stronger attributes for empty containers to ensure they stand out as drop targets
|
||||||
|
if (!hasImages) {
|
||||||
|
container.setAttribute('data-empty', 'true');
|
||||||
|
// Setting tabindex makes the element focusable which can help with accessibility
|
||||||
|
container.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [data, productImages]); // Add productImages as a dependency to re-run when images change
|
||||||
|
|
||||||
|
// Effect to register browser-level drag events on product containers
|
||||||
|
useEffect(() => {
|
||||||
|
// For each product container
|
||||||
|
data.forEach((_, index) => {
|
||||||
|
const container = document.getElementById(`product-${index}`);
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
// Define handlers for native browser drag events
|
||||||
|
const handleNativeDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (getProductImages(index).length === 0) {
|
||||||
|
// Highlight empty containers during dragover
|
||||||
|
container.classList.add('border-primary', 'bg-primary/5');
|
||||||
|
setActiveDroppableId(`product-${index}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNativeDragLeave = (e: DragEvent) => {
|
||||||
|
if (getProductImages(index).length === 0) {
|
||||||
|
// Remove highlight when drag leaves
|
||||||
|
container.classList.remove('border-primary', 'bg-primary/5');
|
||||||
|
if (activeDroppableId === `product-${index}`) {
|
||||||
|
setActiveDroppableId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add these handlers
|
||||||
|
container.addEventListener('dragover', handleNativeDragOver);
|
||||||
|
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 () => {
|
||||||
|
container.removeEventListener('dragover', handleNativeDragOver);
|
||||||
|
container.removeEventListener('dragleave', handleNativeDragLeave);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [data, productImages]); // 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) => {
|
||||||
// Create a copy of the data
|
// Create a copy of the data
|
||||||
@@ -221,19 +364,37 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
const activeContainer = findContainer(activeId.toString());
|
const activeContainer = findContainer(activeId.toString());
|
||||||
let overContainer = null;
|
let overContainer = null;
|
||||||
|
|
||||||
// Determine the target container:
|
// Check if overId is a product container directly
|
||||||
// 1. First check if the over.id is a product container ID directly
|
if (typeof overId === 'string' && overId.toString().startsWith('product-')) {
|
||||||
if (typeof overId === 'string' && overId.startsWith('product-')) {
|
overContainer = overId.toString().split('-')[1];
|
||||||
overContainer = overId.split('-')[1];
|
console.log('Dropping directly onto product container:', overContainer);
|
||||||
}
|
}
|
||||||
// 2. Otherwise, it might be another image, so find its container
|
// Otherwise check if it's an image, so find its container
|
||||||
else {
|
else {
|
||||||
overContainer = findContainer(overId.toString());
|
overContainer = findContainer(overId.toString());
|
||||||
|
console.log('Dropping onto another image in container:', overContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we couldn't determine either container, do nothing
|
// Log what was detected for debugging
|
||||||
if (!activeContainer || !overContainer) {
|
console.log('Drag end detected:', {
|
||||||
console.log('Could not determine containers', { activeContainer, overContainer, activeId, overId });
|
activeId,
|
||||||
|
overId,
|
||||||
|
overContainer,
|
||||||
|
activeContainer,
|
||||||
|
isOverProduct: typeof overId === 'string' && overId.toString().startsWith('product-')
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we couldn't determine active container, do nothing
|
||||||
|
if (!activeContainer) {
|
||||||
|
console.log('Could not determine source container', { activeContainer, activeId });
|
||||||
|
setActiveId(null);
|
||||||
|
setActiveImage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't determine the over container, do nothing
|
||||||
|
if (!overContainer) {
|
||||||
|
console.log('Could not determine target container', { overContainer, overId });
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setActiveImage(null);
|
setActiveImage(null);
|
||||||
return;
|
return;
|
||||||
@@ -251,14 +412,88 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If source and target are the same product, handle reordering
|
// IMPORTANT: If source and target are different products, ALWAYS prioritize moving over reordering
|
||||||
if (sourceProductIndex === targetProductIndex) {
|
if (sourceProductIndex !== targetProductIndex) {
|
||||||
// Only if overId is an image id (not a product container) - otherwise we'd just drop at the end
|
console.log('Moving image between products', { sourceProductIndex, targetProductIndex });
|
||||||
if (!overId.toString().startsWith('product-')) {
|
|
||||||
|
// Create a copy of the image with the new product index
|
||||||
|
const newImage: ProductImageSortable = {
|
||||||
|
...activeImage,
|
||||||
|
productIndex: targetProductIndex,
|
||||||
|
// Generate a new ID for the image in its new location
|
||||||
|
id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the image from the source product and add to target product
|
||||||
|
setProductImages(items => {
|
||||||
|
// Remove the image from its current product
|
||||||
|
const filteredItems = items.filter(item => item.id !== activeId);
|
||||||
|
|
||||||
|
// Add the image to the target product
|
||||||
|
filteredItems.push(newImage);
|
||||||
|
|
||||||
|
// Check if the operation was successful
|
||||||
|
console.log('Image movement operation', {
|
||||||
|
originalCount: items.length,
|
||||||
|
newCount: filteredItems.length,
|
||||||
|
sourceImages: items.filter(item => item.productIndex === sourceProductIndex).length,
|
||||||
|
targetImagesBefore: items.filter(item => item.productIndex === targetProductIndex).length,
|
||||||
|
targetImagesAfter: filteredItems.filter(item => item.productIndex === targetProductIndex).length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update both products' image_url fields - creating new objects to ensure state updates
|
||||||
|
let updatedData = [...data]; // Start with a fresh copy
|
||||||
|
|
||||||
|
// First remove from source
|
||||||
|
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
|
||||||
|
|
||||||
|
// Then add to target
|
||||||
|
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
|
||||||
|
|
||||||
|
return filteredItems;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Source and target are the same product - this is a reordering operation
|
||||||
|
else {
|
||||||
|
// Only attempt reordering if we have at least 2 images in this container
|
||||||
|
const productImages = getProductImages(sourceProductIndex);
|
||||||
|
|
||||||
|
if (productImages.length >= 2) {
|
||||||
|
console.log('Reordering images within product', { sourceProductIndex, imagesCount: productImages.length });
|
||||||
|
|
||||||
|
// Handle reordering regardless of whether we're over a container or another image
|
||||||
setProductImages(items => {
|
setProductImages(items => {
|
||||||
// Filter to get only the images for this product
|
// Filter to get only the images for this product
|
||||||
const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex);
|
const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex);
|
||||||
|
|
||||||
|
// If dropping onto the container itself, put at the end
|
||||||
|
if (overId.toString().startsWith('product-')) {
|
||||||
|
console.log('Dropping onto container - moving to end');
|
||||||
|
// Find active index
|
||||||
|
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
|
||||||
|
|
||||||
|
if (activeIndex === -1) {
|
||||||
|
return items; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move active item to end (remove and push to end)
|
||||||
|
const newFilteredItems = [...productFilteredItems];
|
||||||
|
const [movedItem] = newFilteredItems.splice(activeIndex, 1);
|
||||||
|
newFilteredItems.push(movedItem);
|
||||||
|
|
||||||
|
// Create a new full list replacing the items for this product with the reordered ones
|
||||||
|
const newItems = items.filter(item => item.productIndex !== sourceProductIndex);
|
||||||
|
newItems.push(...newFilteredItems);
|
||||||
|
|
||||||
|
// Update the product data with the new image order
|
||||||
|
const updatedData = updateProductImageOrder(sourceProductIndex, newFilteredItems);
|
||||||
|
|
||||||
|
return newItems;
|
||||||
|
}
|
||||||
|
|
||||||
// Find indices within the filtered list
|
// Find indices within the filtered list
|
||||||
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
|
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
|
||||||
const overIndex = productFilteredItems.findIndex(item => item.id === overId);
|
const overIndex = productFilteredItems.findIndex(item => item.id === overId);
|
||||||
@@ -281,39 +516,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
return newItems;
|
return newItems;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// If source and target are different products, handle reassigning
|
|
||||||
else {
|
|
||||||
// Create a copy of the image with the new product index
|
|
||||||
const newImage: ProductImageSortable = {
|
|
||||||
...activeImage,
|
|
||||||
productIndex: targetProductIndex,
|
|
||||||
// Generate a new ID for the image in its new location
|
|
||||||
id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove the image from the source product and add to target product
|
|
||||||
setProductImages(items => {
|
|
||||||
// Remove the image from its current product
|
|
||||||
const filteredItems = items.filter(item => item.id !== activeId);
|
|
||||||
|
|
||||||
// Add the image to the target product
|
|
||||||
filteredItems.push(newImage);
|
|
||||||
|
|
||||||
// Update both products' image_url fields - creating new objects to ensure state updates
|
|
||||||
let updatedData = [...data]; // Start with a fresh copy
|
|
||||||
|
|
||||||
// First remove from source
|
|
||||||
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
|
|
||||||
|
|
||||||
// Then add to target
|
|
||||||
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
|
|
||||||
|
|
||||||
// Show notification
|
|
||||||
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
|
|
||||||
|
|
||||||
return filteredItems;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
@@ -375,7 +577,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
|
|
||||||
return newData;
|
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;
|
||||||
@@ -621,18 +823,72 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Add product IDs to the valid droppable elements
|
// Function to remove an image
|
||||||
useEffect(() => {
|
const removeImage = async (imageIndex: number) => {
|
||||||
// Add data-droppable attributes to make product containers easier to identify
|
const image = productImages[imageIndex];
|
||||||
data.forEach((_, index) => {
|
if (!image) return;
|
||||||
const container = document.getElementById(`product-${index}`);
|
|
||||||
if (container) {
|
try {
|
||||||
container.setAttribute('data-droppable', 'true');
|
// Extract the filename from the URL
|
||||||
container.setAttribute('aria-dropeffect', 'move');
|
const urlParts = image.imageUrl.split('/');
|
||||||
|
const filename = urlParts[urlParts.length - 1];
|
||||||
|
|
||||||
|
// Call API to delete the image
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/delete-image`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl: image.imageUrl,
|
||||||
|
filename
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete image');
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}, [data]);
|
// Remove the image from our state
|
||||||
|
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
|
||||||
|
|
||||||
|
// Remove the image URL from the product data
|
||||||
|
const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl);
|
||||||
|
|
||||||
|
toast.success('Image removed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error:', error);
|
||||||
|
toast.error(`Failed to remove image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle calling onSubmit with the current data
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(data, file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submit error:', error);
|
||||||
|
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [data, file, onSubmit]);
|
||||||
|
|
||||||
|
// Function to ensure URLs are properly formatted with absolute paths
|
||||||
|
const getFullImageUrl = (url: string): string => {
|
||||||
|
// If the URL is already absolute (starts with http:// or https://) return it as is
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, it's a relative URL, prepend the domain
|
||||||
|
const baseUrl = 'https://inventory.acot.site';
|
||||||
|
// Make sure url starts with / for path
|
||||||
|
const path = url.startsWith('/') ? url : `/${url}`;
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Generic dropzone component
|
// Generic dropzone component
|
||||||
const GenericDropzone = () => {
|
const GenericDropzone = () => {
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
@@ -718,77 +974,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to remove an image
|
|
||||||
const removeImage = async (imageIndex: number) => {
|
|
||||||
const image = productImages[imageIndex];
|
|
||||||
if (!image) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract the filename from the URL
|
|
||||||
const urlParts = image.imageUrl.split('/');
|
|
||||||
const filename = urlParts[urlParts.length - 1];
|
|
||||||
|
|
||||||
// Call API to delete the image
|
|
||||||
const response = await fetch(`${config.apiUrl}/import/delete-image`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
imageUrl: image.imageUrl,
|
|
||||||
filename
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete image');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the image from our state
|
|
||||||
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
|
|
||||||
|
|
||||||
// Remove the image URL from the product data
|
|
||||||
const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl);
|
|
||||||
|
|
||||||
toast.success('Image removed successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Delete error:', error);
|
|
||||||
toast.error(`Failed to remove image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle calling onSubmit with the current data
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await onSubmit(data, file);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submit error:', error);
|
|
||||||
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}, [data, file, onSubmit]);
|
|
||||||
|
|
||||||
// Get images for a specific product
|
|
||||||
const getProductImages = (productIndex: number) => {
|
|
||||||
return productImages.filter(img => img.productIndex === productIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to ensure URLs are properly formatted with absolute paths
|
|
||||||
const getFullImageUrl = (url: string): string => {
|
|
||||||
// If the URL is already absolute (starts with http:// or https://) return it as is
|
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, it's a relative URL, prepend the domain
|
|
||||||
const baseUrl = 'https://inventory.acot.site';
|
|
||||||
// Make sure url starts with / for path
|
|
||||||
const path = url.startsWith('/') ? url : `/${url}`;
|
|
||||||
return `${baseUrl}${path}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Component for individual unassigned image item
|
// Component for individual unassigned image item
|
||||||
const UnassignedImageItem = ({ image, index }: { image: UnassignedImage; index: number }) => {
|
const UnassignedImageItem = ({ image, index }: { image: UnassignedImage; index: number }) => {
|
||||||
@@ -980,12 +1165,18 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a new style object with fixed dimensions to prevent distortion
|
||||||
const style = {
|
const style = {
|
||||||
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 ? 10 : 0, // Higher z-index when dragging
|
||||||
touchAction: 'none' as 'none', // Prevent touch scrolling during drag
|
touchAction: 'none' as 'none', // Prevent touch scrolling during drag
|
||||||
|
width: '96px',
|
||||||
|
height: '96px',
|
||||||
|
flexShrink: 0,
|
||||||
|
flexGrow: 0,
|
||||||
|
position: 'relative' as '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
|
||||||
@@ -996,7 +1187,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
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 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"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
@@ -1015,7 +1206,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
ref={deleteButtonRef}
|
ref={deleteButtonRef}
|
||||||
className="absolute top-1 right-1 bg-black/60 rounded-full p-1.5 text-white z-10 hover:bg-black/80"
|
className="absolute top-1 right-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(); // Prevent triggering drag listeners
|
e.stopPropagation(); // Prevent triggering drag listeners
|
||||||
@@ -1031,7 +1222,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.5 w-3.5" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Fix zoom button with proper state management */}
|
{/* Fix zoom button with proper state management */}
|
||||||
@@ -1039,7 +1230,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.5 text-white z-10 hover:bg-black/80"
|
className="absolute bottom-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(); // Prevent triggering drag listeners
|
e.stopPropagation(); // Prevent triggering drag listeners
|
||||||
@@ -1055,7 +1246,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
e.stopPropagation(); // Prevent drag from starting on touch
|
e.stopPropagation(); // Prevent drag from starting on touch
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<Maximize2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</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">
|
<DialogContent className="sm:max-w-[650px] max-h-[90vh] p-6 bg-background/95 backdrop-blur-sm border border-border rounded-lg shadow-2xl">
|
||||||
@@ -1091,15 +1282,43 @@ 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 flex-wrap gap-2 overflow-x-auto flex-1 min-h-[6rem] rounded-md p-2 transition-all",
|
"flex-1 min-h-[6rem] rounded-md p-2 transition-all",
|
||||||
isValidDropTarget && "border border-dashed",
|
// Always add a border for empty containers to make them visible as drop targets
|
||||||
|
!hasImages && "border-2 border-dashed border-secondary-foreground/30",
|
||||||
|
// Active drop target styling
|
||||||
isValidDropTarget && isActiveDropTarget
|
isValidDropTarget && isActiveDropTarget
|
||||||
? "border-primary bg-primary/10 border-2"
|
? "border-2 border-dashed border-primary bg-primary/10"
|
||||||
: isValidDropTarget
|
: isValidDropTarget && !hasImages
|
||||||
? "border-muted-foreground/30 hover:border-primary/50 hover:bg-primary/5"
|
? "border-2 border-dashed border-muted-foreground/40 bg-muted/20"
|
||||||
: ""
|
: isValidDropTarget
|
||||||
|
? "border border-dashed border-muted-foreground/30"
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add a DroppableContainer component for empty product containers
|
||||||
|
const DroppableContainer = ({ id, children, isEmpty }: { id: string; children: React.ReactNode; isEmpty: boolean }) => {
|
||||||
|
const { setNodeRef } = useDroppable({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
type: 'container',
|
||||||
|
isEmpty
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
id={id}
|
||||||
|
data-droppable="true"
|
||||||
|
data-empty={isEmpty ? "true" : "false"}
|
||||||
|
className={`w-full h-full ${!isEmpty ? "flex flex-row flex-wrap gap-2" : ""}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1159,7 +1378,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
"ring-2 ring-primary bg-primary/5"
|
"ring-2 ring-primary bg-primary/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent id={`product-${index}`} className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
<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>
|
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
||||||
@@ -1168,7 +1387,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{/* Dropzone for image upload always on the left */}
|
{/* Dropzone for image upload always on the left */}
|
||||||
<ImageDropzone productIndex={index} />
|
<ImageDropzone productIndex={index} />
|
||||||
@@ -1176,22 +1395,44 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
{/* Images appear to the right of the dropzone in a sortable container */}
|
{/* Images appear to the right of the dropzone in a sortable container */}
|
||||||
<div
|
<div
|
||||||
className={getProductContainerClasses(index)}
|
className={getProductContainerClasses(index)}
|
||||||
data-product-id={`product-${index}`}
|
style={{
|
||||||
id={`product-${index}`}
|
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}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<DroppableContainer
|
||||||
items={getProductImages(index).map(img => img.id)}
|
id={`product-${index}`}
|
||||||
strategy={horizontalListSortingStrategy}
|
isEmpty={getProductImages(index).length === 0}
|
||||||
>
|
>
|
||||||
{getProductImages(index).map((image, imgIndex) => (
|
{getProductImages(index).length > 0 ? (
|
||||||
<SortableImage
|
<SortableContext
|
||||||
key={image.id}
|
items={getProductImages(index).map(img => img.id)}
|
||||||
image={image}
|
strategy={horizontalListSortingStrategy}
|
||||||
productIndex={index}
|
>
|
||||||
imgIndex={imgIndex}
|
{getProductImages(index).map((image, imgIndex) => (
|
||||||
/>
|
<SortableImage
|
||||||
))}
|
key={image.id}
|
||||||
</SortableContext>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Hidden file input for backwards compatibility */}
|
{/* Hidden file input for backwards compatibility */}
|
||||||
@@ -1211,9 +1452,17 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag overlay for showing the dragged image */}
|
{/* Drag overlay for showing the dragged image */}
|
||||||
<DragOverlay adjustScale zIndex={1000} dropAnimation={null}>
|
<DragOverlay adjustScale={false} zIndex={1000} dropAnimation={null}>
|
||||||
{activeId && activeImage && (
|
{activeId && activeImage && (
|
||||||
<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">
|
<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
|
<img
|
||||||
src={getFullImageUrl(activeImage.imageUrl)}
|
src={getFullImageUrl(activeImage.imageUrl)}
|
||||||
alt="Dragging image"
|
alt="Dragging image"
|
||||||
|
|||||||
Reference in New Issue
Block a user