Make images draggable between products, add zoom
This commit is contained in:
@@ -10,6 +10,113 @@ const fs = require('fs');
|
||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
// Create a Map to track image upload times and their scheduled deletion
|
||||
const imageUploadMap = new Map();
|
||||
|
||||
// Function to schedule image deletion after 24 hours
|
||||
const scheduleImageDeletion = (filename, filePath) => {
|
||||
// Delete any existing timeout for this file
|
||||
if (imageUploadMap.has(filename)) {
|
||||
clearTimeout(imageUploadMap.get(filename).timeoutId);
|
||||
}
|
||||
|
||||
// Schedule deletion after 24 hours (24 * 60 * 60 * 1000 ms)
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log(`Auto-deleting image after 24 hours: ${filename}`);
|
||||
|
||||
// Check if file exists before trying to delete
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`Successfully auto-deleted image: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`Error auto-deleting image ${filename}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`File already deleted: ${filename}`);
|
||||
}
|
||||
|
||||
// Remove from tracking map
|
||||
imageUploadMap.delete(filename);
|
||||
}, 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
// Store upload time and timeout ID
|
||||
imageUploadMap.set(filename, {
|
||||
uploadTime: new Date(),
|
||||
timeoutId: timeoutId,
|
||||
filePath: filePath
|
||||
});
|
||||
};
|
||||
|
||||
// Function to clean up scheduled deletions on server restart
|
||||
const cleanupImagesOnStartup = () => {
|
||||
console.log('Checking for images to clean up...');
|
||||
|
||||
// Check if uploads directory exists
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
console.log('Uploads directory does not exist');
|
||||
return;
|
||||
}
|
||||
|
||||
// Read all files in the directory
|
||||
fs.readdir(uploadsDir, (err, files) => {
|
||||
if (err) {
|
||||
console.error('Error reading uploads directory:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let countDeleted = 0;
|
||||
|
||||
files.forEach(filename => {
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
|
||||
// Get file stats
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileCreationTime = stats.birthtime || stats.ctime; // birthtime might not be available on all systems
|
||||
const ageMs = now.getTime() - fileCreationTime.getTime();
|
||||
|
||||
// If file is older than 24 hours, delete it
|
||||
if (ageMs > 24 * 60 * 60 * 1000) {
|
||||
fs.unlinkSync(filePath);
|
||||
countDeleted++;
|
||||
console.log(`Deleted old image on startup: ${filename} (age: ${Math.round(ageMs / (60 * 60 * 1000))} hours)`);
|
||||
} else {
|
||||
// Schedule deletion for remaining time
|
||||
const remainingMs = (24 * 60 * 60 * 1000) - ageMs;
|
||||
console.log(`Scheduling deletion for ${filename} in ${Math.round(remainingMs / (60 * 60 * 1000))} hours`);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`Successfully auto-deleted scheduled image: ${filename}`);
|
||||
} catch (error) {
|
||||
console.error(`Error auto-deleting scheduled image ${filename}:`, error);
|
||||
}
|
||||
}
|
||||
imageUploadMap.delete(filename);
|
||||
}, remainingMs);
|
||||
|
||||
imageUploadMap.set(filename, {
|
||||
uploadTime: fileCreationTime,
|
||||
timeoutId: timeoutId,
|
||||
filePath: filePath
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing file ${filename}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Cleanup completed: ${countDeleted} old images deleted, ${imageUploadMap.size} images scheduled for deletion`);
|
||||
});
|
||||
};
|
||||
|
||||
// Run cleanup on server start
|
||||
cleanupImagesOnStartup();
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -138,6 +245,9 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
|
||||
const baseUrl = 'https://inventory.acot.site';
|
||||
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
||||
|
||||
// Schedule this image for deletion in 24 hours
|
||||
scheduleImageDeletion(req.file.filename, filePath);
|
||||
|
||||
// Return success response with image URL
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
@@ -145,7 +255,7 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
|
||||
fileName: req.file.filename,
|
||||
mimetype: req.file.mimetype,
|
||||
fullPath: filePath,
|
||||
message: 'Image uploaded successfully'
|
||||
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -173,6 +283,12 @@ router.delete('/delete-image', (req, res) => {
|
||||
// Delete the file
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
// Clear any scheduled deletion for this file
|
||||
if (imageUploadMap.has(filename)) {
|
||||
clearTimeout(imageUploadMap.get(filename).timeoutId);
|
||||
imageUploadMap.delete(filename);
|
||||
}
|
||||
|
||||
// Return success response
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState, useRef, useEffect } from "react";
|
||||
import { useRsi } from "../../hooks/useRsi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Upload, Trash2, AlertCircle, GripVertical } from "lucide-react";
|
||||
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -18,16 +18,31 @@ import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
getFirstCollision,
|
||||
useDndMonitor,
|
||||
DragMoveEvent,
|
||||
closestCorners,
|
||||
CollisionDetection
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
rectSortingStrategy
|
||||
rectSortingStrategy,
|
||||
horizontalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Props<T extends string> = {
|
||||
data: any[];
|
||||
@@ -53,6 +68,26 @@ 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,
|
||||
@@ -66,43 +101,259 @@ export const ImageUploadStep = <T extends string>({
|
||||
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
|
||||
const [processingBulk, setProcessingBulk] = useState(false);
|
||||
const [showUnassigned, setShowUnassigned] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [activeImage, setActiveImage] = useState<ProductImageSortable | null>(null);
|
||||
|
||||
// Set up sensors for drag and drop
|
||||
// Set up sensors for drag and drop with enhanced configuration
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(PointerSensor, {
|
||||
// Make it responsive with minimal constraints
|
||||
activationConstraint: {
|
||||
distance: 3, // Reduced distance for more responsive drag
|
||||
tolerance: 5
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Handle drag end event to reorder images
|
||||
const handleDragEnd = (event: DragEndEvent, productIndex: number) => {
|
||||
// Track which product container is being hovered over
|
||||
const [activeDroppableId, setActiveDroppableId] = useState<string | null>(null);
|
||||
|
||||
// Handle drag start to set active image and prevent default behavior
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
|
||||
const activeImageItem = productImages.find(img => img.id === active.id);
|
||||
setActiveId(active.id.toString());
|
||||
if (activeImageItem) {
|
||||
setActiveImage(activeImageItem);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag over to track which product container is being hovered
|
||||
const handleDragOver = (event: DragMoveEvent) => {
|
||||
const { 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 overImage = productImages.find(img => img.id === over.id);
|
||||
if (overImage) {
|
||||
setActiveDroppableId(`product-${overImage.productIndex}`);
|
||||
} else {
|
||||
setActiveDroppableId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor drag events to prevent browser behaviors
|
||||
useEffect(() => {
|
||||
// Add a global event listener to prevent browser's native drag behavior
|
||||
const preventDefaultDragImage = (event: DragEvent) => {
|
||||
if (activeId) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dragstart', preventDefaultDragImage);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragstart', preventDefaultDragImage);
|
||||
};
|
||||
}, [activeId]);
|
||||
|
||||
// Function to find the 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 remove an image URL from a product
|
||||
const removeImageFromProduct = (productIndex: number, imageUrl: string) => {
|
||||
// Create a copy of the data
|
||||
const newData = [...data];
|
||||
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Get current image URLs
|
||||
let currentUrls = product.image_url ?
|
||||
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
|
||||
: [];
|
||||
|
||||
// Filter out all instances of the URL we're removing
|
||||
currentUrls = currentUrls.filter((url: string) => url && url !== imageUrl);
|
||||
|
||||
// Update the product
|
||||
product.image_url = currentUrls.join(',');
|
||||
|
||||
// This is important - actually update the data reference in the parent component
|
||||
// by passing the newData back to onSubmit, which will update the parent state
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Handle drag end event to reorder or reassign images
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
// Reset active droppable
|
||||
setActiveDroppableId(null);
|
||||
|
||||
if (!over) {
|
||||
setActiveId(null);
|
||||
setActiveImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
// Find the containers (product indices) for the active element
|
||||
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];
|
||||
}
|
||||
// 2. Otherwise, it might be another image, so find its container
|
||||
else {
|
||||
overContainer = findContainer(overId.toString());
|
||||
}
|
||||
|
||||
// If we couldn't determine either container, do nothing
|
||||
if (!activeContainer || !overContainer) {
|
||||
console.log('Could not determine containers', { activeContainer, overContainer, activeId, overId });
|
||||
setActiveId(null);
|
||||
setActiveImage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert containers to numbers
|
||||
const sourceProductIndex = parseInt(activeContainer);
|
||||
const targetProductIndex = parseInt(overContainer);
|
||||
|
||||
// Find the active image
|
||||
const activeImage = productImages.find(img => img.id === activeId);
|
||||
if (!activeImage) {
|
||||
setActiveId(null);
|
||||
setActiveImage(null);
|
||||
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-')) {
|
||||
setProductImages(items => {
|
||||
// Filter to get only the images for this product
|
||||
const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex);
|
||||
|
||||
// Find indices within the filtered list
|
||||
const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
|
||||
const overIndex = productFilteredItems.findIndex(item => item.id === overId);
|
||||
|
||||
// If one of the indices is not found or they're the same, do nothing
|
||||
if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Reorder the filtered items
|
||||
const newFilteredItems = arrayMove(productFilteredItems, activeIndex, overIndex);
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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 => {
|
||||
// Filter to get only the images for this product
|
||||
const productFilteredItems = items.filter(item => item.productIndex === productIndex);
|
||||
// Remove the image from its current product
|
||||
const filteredItems = items.filter(item => item.id !== activeId);
|
||||
|
||||
// Find the indices within this filtered list
|
||||
const oldIndex = productFilteredItems.findIndex(item => item.id === active.id);
|
||||
const newIndex = productFilteredItems.findIndex(item => item.id === over.id);
|
||||
// Add the image to the target product
|
||||
filteredItems.push(newImage);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return items;
|
||||
// Update both products' image_url fields - creating new objects to ensure state updates
|
||||
let updatedData = [...data]; // Start with a fresh copy
|
||||
|
||||
// Reorder the filtered items
|
||||
const newFilteredItems = arrayMove(productFilteredItems, oldIndex, newIndex);
|
||||
// First remove from source
|
||||
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
|
||||
|
||||
// Create a new full list replacing the items for this product with the reordered ones
|
||||
const newItems = items.filter(item => item.productIndex !== productIndex);
|
||||
newItems.push(...newFilteredItems);
|
||||
// Then add to target
|
||||
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
|
||||
|
||||
// Update the product data with the new image order
|
||||
updateProductImageOrder(productIndex, newFilteredItems);
|
||||
// Show notification
|
||||
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
|
||||
|
||||
return newItems;
|
||||
return filteredItems;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
setActiveImage(null);
|
||||
};
|
||||
|
||||
// Function to add an image URL to a product
|
||||
const addImageToProduct = (productIndex: number, imageUrl: string) => {
|
||||
// Create a copy of the data
|
||||
const newData = [...data];
|
||||
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Get the current image URLs or initialize as empty array
|
||||
let currentUrls = product.image_url ?
|
||||
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
|
||||
: [];
|
||||
|
||||
// If it's not an array, convert to array
|
||||
if (!Array.isArray(currentUrls)) {
|
||||
currentUrls = [currentUrls];
|
||||
}
|
||||
|
||||
// Filter out empty values and make sure the URL doesn't already exist
|
||||
currentUrls = currentUrls.filter((url: string) => url);
|
||||
|
||||
// Only add if the URL doesn't already exist
|
||||
if (!currentUrls.includes(imageUrl)) {
|
||||
// Add the new URL
|
||||
currentUrls.push(imageUrl);
|
||||
}
|
||||
|
||||
// Update the product
|
||||
product.image_url = currentUrls.join(',');
|
||||
|
||||
// Update the data
|
||||
newData[productIndex] = product;
|
||||
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Function to update product data with the new image order
|
||||
@@ -173,7 +424,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
|
||||
// Update the product data with the new image URL
|
||||
updateProductWithImageUrl(productIndex, result.imageUrl);
|
||||
const updatedData = addImageToProduct(productIndex, result.imageUrl);
|
||||
|
||||
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
||||
} catch (error) {
|
||||
@@ -349,6 +600,39 @@ export const ImageUploadStep = <T extends string>({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add this CSS for preventing browser drag behavior
|
||||
useEffect(() => {
|
||||
// Add a custom style element to the document head
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
.no-native-drag {
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
.no-native-drag img {
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleEl);
|
||||
|
||||
return () => {
|
||||
// Clean up on unmount
|
||||
document.head.removeChild(styleEl);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
// Generic dropzone component
|
||||
const GenericDropzone = () => {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
@@ -435,42 +719,6 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
};
|
||||
|
||||
// Function to trigger file input click
|
||||
|
||||
// Function to update product data with image URL
|
||||
const updateProductWithImageUrl = (productIndex: number, imageUrl: string) => {
|
||||
// Create a copy of the data
|
||||
const newData = [...data];
|
||||
|
||||
// Get the current product
|
||||
const product = newData[productIndex];
|
||||
|
||||
// Get the current image URLs or initialize as empty array
|
||||
let currentUrls = product.image_url ?
|
||||
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
|
||||
: [];
|
||||
|
||||
// If it's not an array, convert to array
|
||||
if (!Array.isArray(currentUrls)) {
|
||||
currentUrls = [currentUrls];
|
||||
}
|
||||
|
||||
// Filter out empty values
|
||||
currentUrls = currentUrls.filter((url: string) => url);
|
||||
|
||||
// Add the new URL
|
||||
currentUrls.push(imageUrl);
|
||||
|
||||
// Update the product
|
||||
product.image_url = currentUrls.join(',');
|
||||
|
||||
// Update the data
|
||||
newData[productIndex] = product;
|
||||
|
||||
// Return the updated data
|
||||
return newData;
|
||||
};
|
||||
|
||||
// Function to remove an image
|
||||
const removeImage = async (imageIndex: number) => {
|
||||
const image = productImages[imageIndex];
|
||||
@@ -501,19 +749,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
|
||||
|
||||
// Remove the image URL from the product data
|
||||
const newData = [...data];
|
||||
const product = newData[image.productIndex];
|
||||
|
||||
// Get current image URLs
|
||||
let currentUrls = product.image_url ?
|
||||
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url)
|
||||
: [];
|
||||
|
||||
// Filter out empty values and the URL we're removing
|
||||
currentUrls = currentUrls.filter((url: string) => url && url !== image.imageUrl);
|
||||
|
||||
// Update the product
|
||||
product.image_url = currentUrls.join(',');
|
||||
const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl);
|
||||
|
||||
toast.success('Image removed successfully');
|
||||
} catch (error) {
|
||||
@@ -522,7 +758,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle submit
|
||||
// Handle calling onSubmit with the current data
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
@@ -554,6 +790,89 @@ export const ImageUploadStep = <T extends string>({
|
||||
return `${baseUrl}${path}`;
|
||||
};
|
||||
|
||||
// Component for individual unassigned image item
|
||||
const UnassignedImageItem = ({ image, index }: { image: UnassignedImage; index: number }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative border rounded-md overflow-hidden">
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={`Unassigned image ${index + 1}`}
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Select onValueChange={(value) => assignImageToProduct(index, parseInt(value))}>
|
||||
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white">
|
||||
<SelectValue placeholder="Assign to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.map((product: any, productIndex: number) => (
|
||||
<SelectItem key={productIndex} value={productIndex.toString()}>
|
||||
{product.name || `Product #${productIndex + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeUnassignedImage(index);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Zoom button for unassigned images */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
className="absolute top-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();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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={() => setDialogOpen(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={image.previewUrl}
|
||||
alt={`Unassigned image: ${image.file.name}`}
|
||||
className="max-h-[70vh] max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||
{`Unassigned image: ${image.file.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for displaying unassigned images
|
||||
const UnassignedImagesSection = () => {
|
||||
if (!showUnassigned || unassignedImages.length === 0) return null;
|
||||
@@ -580,38 +899,7 @@ export const ImageUploadStep = <T extends string>({
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{unassignedImages.map((image, index) => (
|
||||
<div key={index} className="relative border rounded-md overflow-hidden">
|
||||
<img
|
||||
src={image.previewUrl}
|
||||
alt={`Unassigned image ${index + 1}`}
|
||||
className="h-28 w-full object-cover"
|
||||
/>
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Select onValueChange={(value) => assignImageToProduct(index, parseInt(value))}>
|
||||
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white">
|
||||
<SelectValue placeholder="Assign to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{data.map((product: any, productIndex: number) => (
|
||||
<SelectItem key={productIndex} value={productIndex.toString()}>
|
||||
{product.name || `Product #${productIndex + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white"
|
||||
onClick={() => removeUnassignedImage(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UnassignedImageItem key={index} image={image} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,8 +907,63 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
};
|
||||
|
||||
// Sortable Image component
|
||||
// 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
|
||||
const SortableImage = ({ image, productIndex, imgIndex }: { image: ProductImageSortable, productIndex: number, imgIndex: number }) => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -628,20 +971,32 @@ export const ImageUploadStep = <T extends string>({
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: image.id });
|
||||
} = useSortable({
|
||||
id: image.id,
|
||||
data: {
|
||||
productIndex,
|
||||
image,
|
||||
type: 'image'
|
||||
}
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1 : 0
|
||||
zIndex: isDragging ? 10 : 0, // Higher z-index when dragging
|
||||
touchAction: 'none' as 'none', // Prevent touch scrolling during drag
|
||||
};
|
||||
|
||||
// Create a ref for the buttons to exclude them from drag listeners
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const zoomButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing"
|
||||
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"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
@@ -655,30 +1010,105 @@ export const ImageUploadStep = <T extends string>({
|
||||
<img
|
||||
src={getFullImageUrl(image.imageUrl)}
|
||||
alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false} // Prevent browser's native image drag
|
||||
className="h-full w-full object-cover select-none no-native-drag"
|
||||
draggable={false}
|
||||
/>
|
||||
<button
|
||||
className="absolute top-1 right-1 bg-black/60 rounded-full p-0.5 text-white z-10"
|
||||
ref={deleteButtonRef}
|
||||
className="absolute top-1 right-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
|
||||
removeImage(productImages.findIndex(img => img.id === image.id));
|
||||
}}
|
||||
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
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Fix zoom button with proper state management */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<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"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent triggering drag listeners
|
||||
setDialogOpen(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={() => setDialogOpen(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={getFullImageUrl(image.imageUrl)}
|
||||
alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
||||
className="max-h-[70vh] max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground text-center">
|
||||
{`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to add more visual indication when dragging
|
||||
const getProductContainerClasses = (index: number) => {
|
||||
const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
|
||||
const isActiveDropTarget = activeDroppableId === `product-${index}`;
|
||||
|
||||
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",
|
||||
isValidDropTarget && isActiveDropTarget
|
||||
? "border-primary bg-primary/10 border-2"
|
||||
: isValidDropTarget
|
||||
? "border-muted-foreground/30 hover:border-primary/50 hover:bg-primary/5"
|
||||
: ""
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-semibold mb-2">Add Product Images</h2>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Upload images for each product. The images will be added to the Image URL field.
|
||||
Upload images for each product. Drag images to reorder them or move them between products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -691,33 +1121,67 @@ export const ImageUploadStep = <T extends string>({
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-2">
|
||||
{data.map((product: any, index: number) => (
|
||||
<Card key={index} className="p-3">
|
||||
<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'}
|
||||
<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 id={`product-${index}`} 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>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Dropzone for image upload always on the left */}
|
||||
<ImageDropzone productIndex={index} />
|
||||
<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="flex flex-wrap gap-2 overflow-x-auto flex-1">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => handleDragEnd(event, index)}
|
||||
{/* Images appear to the right of the dropzone in a sortable container */}
|
||||
<div
|
||||
className={getProductContainerClasses(index)}
|
||||
data-product-id={`product-${index}`}
|
||||
id={`product-${index}`}
|
||||
>
|
||||
<SortableContext
|
||||
items={getProductImages(index).map(img => img.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{getProductImages(index).map((image, imgIndex) => (
|
||||
<SortableImage
|
||||
@@ -728,24 +1192,39 @@ export const ImageUploadStep = <T extends string>({
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</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)}
|
||||
/>
|
||||
{/* 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Drag overlay for showing the dragged image */}
|
||||
<DragOverlay adjustScale 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">
|
||||
<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>
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
Reference in New Issue
Block a user