diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index cde2136..a65a9a8 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -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, diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx index b369f37..4e3578a 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ImageUploadStep/ImageUploadStep.tsx @@ -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 = { 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 = ({ data, file, @@ -66,43 +101,259 @@ export const ImageUploadStep = ({ const [unassignedImages, setUnassignedImages] = useState([]); const [processingBulk, setProcessingBulk] = useState(false); const [showUnassigned, setShowUnassigned] = useState(false); + const [activeId, setActiveId] = useState(null); + const [activeImage, setActiveImage] = useState(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(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 = ({ ); // 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 = ({ }; }, []); + // 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 = ({ ); }; - // 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 = ({ 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 = ({ } }; - // 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 = ({ return `${baseUrl}${path}`; }; + // Component for individual unassigned image item + const UnassignedImageItem = ({ image, index }: { image: UnassignedImage; index: number }) => { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( +
+ {`Unassigned +
+

{image.file.name}

+
+ + +
+
+ {/* Zoom button for unassigned images */} + + + + + +
+ +
+ {`Unassigned +
+
+ {`Unassigned image: ${image.file.name}`} +
+
+
+
+
+ ); + }; + // Component for displaying unassigned images const UnassignedImagesSection = () => { if (!showUnassigned || unassignedImages.length === 0) return null; @@ -580,38 +899,7 @@ export const ImageUploadStep = ({
{unassignedImages.map((image, index) => ( -
- {`Unassigned -
-

{image.file.name}

-
- - -
-
-
+ ))}
@@ -619,8 +907,63 @@ export const ImageUploadStep = ({ ); }; - // 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 ( + + + + + +
+ +
+ {alt} +
+
+ {alt || "Product Image"} +
+
+
+
+ ); + }; + + // 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 = ({ 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(null); + const zoomButtonRef = useRef(null); + return (
@@ -655,30 +1010,105 @@ export const ImageUploadStep = ({ {`Product + + {/* Fix zoom button with proper state management */} + + + + + +
+ +
+ {`Product +
+
+ {`Product ${productIndex + 1} - Image ${imgIndex + 1}`} +
+
+
+
)}
); }; + // 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 (

Add Product Images

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

@@ -691,33 +1121,67 @@ export const ImageUploadStep = ({ -
- {data.map((product: any, index: number) => ( - - -
-
-

{product.name || `Product #${index + 1}`}

-
- UPC: {product.upc || 'N/A'} | - Supplier #: {product.supplier_no || 'N/A'} + + {/* Add helper invisible div to handle global styles during drag */} + {activeId && ( +