Drag between products fix
This commit is contained in:
@@ -27,7 +27,8 @@ import {
|
||||
useDndMonitor,
|
||||
DragMoveEvent,
|
||||
closestCorners,
|
||||
CollisionDetection
|
||||
CollisionDetection,
|
||||
useDroppable
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
@@ -68,26 +69,6 @@ type ProductImageSortable = ProductImage & {
|
||||
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>({
|
||||
data,
|
||||
file,
|
||||
@@ -121,6 +102,77 @@ export const ImageUploadStep = <T extends string>({
|
||||
// Track which product container is being hovered over
|
||||
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
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
@@ -134,21 +186,30 @@ export const ImageUploadStep = <T extends string>({
|
||||
|
||||
// Handle drag over to track which product container is being hovered
|
||||
const handleDragOver = (event: DragMoveEvent) => {
|
||||
const { over } = event;
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) {
|
||||
setActiveDroppableId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're over a product container
|
||||
if (typeof over.id === 'string' && over.id.startsWith('product-')) {
|
||||
setActiveDroppableId(over.id);
|
||||
} else {
|
||||
// We might be over another image, so get its product container
|
||||
const activeContainer = findContainer(active.id.toString());
|
||||
let overContainer = null;
|
||||
|
||||
// Check if we're over a product container directly
|
||||
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);
|
||||
if (overImage) {
|
||||
setActiveDroppableId(`product-${overImage.productIndex}`);
|
||||
overContainer = `product-${overImage.productIndex}`;
|
||||
setActiveDroppableId(overContainer);
|
||||
} else {
|
||||
setActiveDroppableId(null);
|
||||
}
|
||||
@@ -171,12 +232,94 @@ export const ImageUploadStep = <T extends string>({
|
||||
};
|
||||
}, [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 image = productImages.find(img => img.id === id);
|
||||
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
|
||||
const removeImageFromProduct = (productIndex: number, imageUrl: string) => {
|
||||
// Create a copy of the data
|
||||
@@ -221,19 +364,37 @@ export const ImageUploadStep = <T extends string>({
|
||||
const activeContainer = findContainer(activeId.toString());
|
||||
let overContainer = null;
|
||||
|
||||
// Determine the target container:
|
||||
// 1. First check if the over.id is a product container ID directly
|
||||
if (typeof overId === 'string' && overId.startsWith('product-')) {
|
||||
overContainer = overId.split('-')[1];
|
||||
// Check if overId is a product container directly
|
||||
if (typeof overId === 'string' && overId.toString().startsWith('product-')) {
|
||||
overContainer = overId.toString().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 {
|
||||
overContainer = findContainer(overId.toString());
|
||||
console.log('Dropping onto another image in container:', overContainer);
|
||||
}
|
||||
|
||||
// If we couldn't determine either container, do nothing
|
||||
if (!activeContainer || !overContainer) {
|
||||
console.log('Could not determine containers', { activeContainer, overContainer, activeId, overId });
|
||||
// Log what was detected for debugging
|
||||
console.log('Drag end detected:', {
|
||||
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);
|
||||
setActiveImage(null);
|
||||
return;
|
||||
@@ -251,14 +412,88 @@ export const ImageUploadStep = <T extends string>({
|
||||
return;
|
||||
}
|
||||
|
||||
// If source and target are the same product, handle reordering
|
||||
if (sourceProductIndex === targetProductIndex) {
|
||||
// Only if overId is an image id (not a product container) - otherwise we'd just drop at the end
|
||||
if (!overId.toString().startsWith('product-')) {
|
||||
// IMPORTANT: If source and target are different products, ALWAYS prioritize moving over reordering
|
||||
if (sourceProductIndex !== targetProductIndex) {
|
||||
console.log('Moving image between products', { sourceProductIndex, targetProductIndex });
|
||||
|
||||
// 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 => {
|
||||
// Filter to get only the images for this product
|
||||
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
|
||||
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
|
||||
const overIndex = productFilteredItems.findIndex(item => item.id === overId);
|
||||
@@ -281,39 +516,6 @@ export const ImageUploadStep = <T extends string>({
|
||||
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);
|
||||
@@ -375,7 +577,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
|
||||
// Function to handle image upload
|
||||
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -621,18 +823,72 @@ export const ImageUploadStep = <T extends string>({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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');
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}, [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
|
||||
const GenericDropzone = () => {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
@@ -718,77 +974,6 @@ export const ImageUploadStep = <T extends string>({
|
||||
</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
|
||||
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 = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : 0, // Higher z-index when dragging
|
||||
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
|
||||
@@ -996,7 +1187,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing 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}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -1015,7 +1206,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
/>
|
||||
<button
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent triggering drag listeners
|
||||
@@ -1031,7 +1222,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
e.stopPropagation(); // Prevent drag from starting on touch
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{/* Fix zoom button with proper state management */}
|
||||
@@ -1039,7 +1230,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent triggering drag listeners
|
||||
@@ -1055,7 +1246,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
e.stopPropagation(); // Prevent drag from starting on touch
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</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">
|
||||
@@ -1091,15 +1282,43 @@ export const ImageUploadStep = <T extends string>({
|
||||
const getProductContainerClasses = (index: number) => {
|
||||
const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
|
||||
const isActiveDropTarget = activeDroppableId === `product-${index}`;
|
||||
const hasImages = getProductImages(index).length > 0;
|
||||
|
||||
return cn(
|
||||
"flex flex-wrap gap-2 overflow-x-auto flex-1 min-h-[6rem] rounded-md p-2 transition-all",
|
||||
isValidDropTarget && "border border-dashed",
|
||||
"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
|
||||
!hasImages && "border-2 border-dashed border-secondary-foreground/30",
|
||||
// Active drop target styling
|
||||
isValidDropTarget && isActiveDropTarget
|
||||
? "border-primary bg-primary/10 border-2"
|
||||
: isValidDropTarget
|
||||
? "border-muted-foreground/30 hover:border-primary/50 hover:bg-primary/5"
|
||||
: ""
|
||||
? "border-2 border-dashed border-primary bg-primary/10"
|
||||
: isValidDropTarget && !hasImages
|
||||
? "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"
|
||||
)}
|
||||
>
|
||||
<CardContent id={`product-${index}`} className="p-0">
|
||||
<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>
|
||||
@@ -1168,7 +1387,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
<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} />
|
||||
@@ -1176,22 +1395,44 @@ export const ImageUploadStep = <T extends string>({
|
||||
{/* Images appear to the right of the dropzone in a sortable container */}
|
||||
<div
|
||||
className={getProductContainerClasses(index)}
|
||||
data-product-id={`product-${index}`}
|
||||
id={`product-${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}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={getProductImages(index).map(img => img.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
<DroppableContainer
|
||||
id={`product-${index}`}
|
||||
isEmpty={getProductImages(index).length === 0}
|
||||
>
|
||||
{getProductImages(index).map((image, imgIndex) => (
|
||||
<SortableImage
|
||||
key={image.id}
|
||||
image={image}
|
||||
productIndex={index}
|
||||
imgIndex={imgIndex}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
{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 */}
|
||||
@@ -1211,9 +1452,17 @@ export const ImageUploadStep = <T extends string>({
|
||||
</div>
|
||||
|
||||
{/* Drag overlay for showing the dragged image */}
|
||||
<DragOverlay adjustScale zIndex={1000} dropAnimation={null}>
|
||||
<DragOverlay adjustScale={false} zIndex={1000} dropAnimation={null}>
|
||||
{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
|
||||
src={getFullImageUrl(activeImage.imageUrl)}
|
||||
alt="Dragging image"
|
||||
|
||||
Reference in New Issue
Block a user