Make images draggable between products, add zoom

This commit is contained in:
2025-02-26 18:20:49 -05:00
parent 41f7f33746
commit e3361cf098
2 changed files with 747 additions and 152 deletions

View File

@@ -10,6 +10,113 @@ const fs = require('fs');
const uploadsDir = path.join('/var/www/html/inventory/uploads/products'); const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
fs.mkdirSync(uploadsDir, { recursive: true }); 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 // Configure multer for file uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: function (req, file, cb) { 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 baseUrl = 'https://inventory.acot.site';
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`; 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 // Return success response with image URL
res.status(200).json({ res.status(200).json({
success: true, success: true,
@@ -145,7 +255,7 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
fileName: req.file.filename, fileName: req.file.filename,
mimetype: req.file.mimetype, mimetype: req.file.mimetype,
fullPath: filePath, fullPath: filePath,
message: 'Image uploaded successfully' message: 'Image uploaded successfully (will auto-delete after 24 hours)'
}); });
} catch (error) { } catch (error) {
@@ -173,6 +283,12 @@ router.delete('/delete-image', (req, res) => {
// Delete the file // Delete the file
fs.unlinkSync(filePath); 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 // Return success response
res.status(200).json({ res.status(200).json({
success: true, success: true,

View File

@@ -1,7 +1,7 @@
import { useCallback, useState, useRef, useEffect } from "react"; import { useCallback, useState, useRef, useEffect } from "react";
import { useRsi } from "../../hooks/useRsi"; import { useRsi } from "../../hooks/useRsi";
import { Button } from "@/components/ui/button"; 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 { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -18,16 +18,31 @@ import {
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
DragEndEvent DragEndEvent,
DragOverlay,
DragStartEvent,
pointerWithin,
rectIntersection,
getFirstCollision,
useDndMonitor,
DragMoveEvent,
closestCorners,
CollisionDetection
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
useSortable, useSortable,
rectSortingStrategy rectSortingStrategy,
horizontalListSortingStrategy
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
type Props<T extends string> = { type Props<T extends string> = {
data: any[]; data: any[];
@@ -53,6 +68,26 @@ type ProductImageSortable = ProductImage & {
id: string; id: string;
}; };
// Custom collision detection algorithm that combines multiple strategies
const customCollisionDetection: CollisionDetection = (args) => {
// First, try pointer intersection
const pointerCollisions = pointerWithin(args);
if (pointerCollisions.length > 0) {
return pointerCollisions;
}
// If no pointer collisions, try rect intersection
const rectCollisions = rectIntersection(args);
if (rectCollisions.length > 0) {
return rectCollisions;
}
// If still no collisions, use closest corners
return closestCorners(args);
};
export const ImageUploadStep = <T extends string>({ export const ImageUploadStep = <T extends string>({
data, data,
file, file,
@@ -66,43 +101,259 @@ export const ImageUploadStep = <T extends string>({
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]); const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
const [processingBulk, setProcessingBulk] = useState(false); const [processingBulk, setProcessingBulk] = useState(false);
const [showUnassigned, setShowUnassigned] = 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( 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, { useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}) })
); );
// Handle drag end event to reorder images // Track which product container is being hovered over
const handleDragEnd = (event: DragEndEvent, productIndex: number) => { 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; 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 => { setProductImages(items => {
// Filter to get only the images for this product // Filter to get only the images for this product
const productFilteredItems = items.filter(item => item.productIndex === productIndex); const productFilteredItems = items.filter(item => item.productIndex === sourceProductIndex);
// Find the indices within this filtered list // Find indices within the filtered list
const oldIndex = productFilteredItems.findIndex(item => item.id === active.id); const activeIndex = productFilteredItems.findIndex(item => item.id === activeId);
const newIndex = productFilteredItems.findIndex(item => item.id === over.id); const overIndex = productFilteredItems.findIndex(item => item.id === overId);
if (oldIndex === -1 || newIndex === -1) return items; // 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 // Reorder the filtered items
const newFilteredItems = arrayMove(productFilteredItems, oldIndex, newIndex); const newFilteredItems = arrayMove(productFilteredItems, activeIndex, overIndex);
// Create a new full list replacing the items for this product with the reordered ones // Create a new full list replacing the items for this product with the reordered ones
const newItems = items.filter(item => item.productIndex !== productIndex); const newItems = items.filter(item => item.productIndex !== sourceProductIndex);
newItems.push(...newFilteredItems); newItems.push(...newFilteredItems);
// Update the product data with the new image order // Update the product data with the new image order
updateProductImageOrder(productIndex, newFilteredItems); const updatedData = updateProductImageOrder(sourceProductIndex, newFilteredItems);
return newItems; return newItems;
}); });
} }
}
// If source and target are different products, handle reassigning
else {
// Create a copy of the image with the new product index
const newImage: ProductImageSortable = {
...activeImage,
productIndex: targetProductIndex,
// Generate a new ID for the image in its new location
id: `image-${targetProductIndex}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
};
// Remove the image from the source product and add to target product
setProductImages(items => {
// Remove the image from its current product
const filteredItems = items.filter(item => item.id !== activeId);
// Add the image to the target product
filteredItems.push(newImage);
// Update both products' image_url fields - creating new objects to ensure state updates
let updatedData = [...data]; // Start with a fresh copy
// First remove from source
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
// Then add to target
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
// Show notification
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
return filteredItems;
});
}
setActiveId(null);
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 // 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 // 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}`}`); toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
} catch (error) { } 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 // Generic dropzone component
const GenericDropzone = () => { const GenericDropzone = () => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({ 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 // Function to remove an image
const removeImage = async (imageIndex: number) => { const removeImage = async (imageIndex: number) => {
const image = productImages[imageIndex]; const image = productImages[imageIndex];
@@ -501,19 +749,7 @@ export const ImageUploadStep = <T extends string>({
setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex)); setProductImages(prev => prev.filter((_, idx) => idx !== imageIndex));
// Remove the image URL from the product data // Remove the image URL from the product data
const newData = [...data]; const updatedData = removeImageFromProduct(image.productIndex, image.imageUrl);
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(',');
toast.success('Image removed successfully'); toast.success('Image removed successfully');
} catch (error) { } 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 () => { const handleSubmit = useCallback(async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
@@ -554,6 +790,89 @@ export const ImageUploadStep = <T extends string>({
return `${baseUrl}${path}`; 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 // Component for displaying unassigned images
const UnassignedImagesSection = () => { const UnassignedImagesSection = () => {
if (!showUnassigned || unassignedImages.length === 0) return null; 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"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{unassignedImages.map((image, index) => ( {unassignedImages.map((image, index) => (
<div key={index} className="relative border rounded-md overflow-hidden"> <UnassignedImageItem key={index} image={image} index={index} />
<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>
))} ))}
</div> </div>
</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 SortableImage = ({ image, productIndex, imgIndex }: { image: ProductImageSortable, productIndex: number, imgIndex: number }) => {
const [dialogOpen, setDialogOpen] = useState(false);
const { const {
attributes, attributes,
listeners, listeners,
@@ -628,20 +971,32 @@ export const ImageUploadStep = <T extends string>({
transform, transform,
transition, transition,
isDragging isDragging
} = useSortable({ id: image.id }); } = useSortable({
id: image.id,
data: {
productIndex,
image,
type: 'image'
}
});
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 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 ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className="relative border rounded-md overflow-hidden h-24 w-24 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing" 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} {...attributes}
{...listeners} {...listeners}
> >
@@ -655,30 +1010,105 @@ export const ImageUploadStep = <T extends string>({
<img <img
src={getFullImageUrl(image.imageUrl)} src={getFullImageUrl(image.imageUrl)}
alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`} alt={`Product ${productIndex + 1} - Image ${imgIndex + 1}`}
className="h-full w-full object-cover" className="h-full w-full object-cover select-none no-native-drag"
draggable={false} // Prevent browser's native image drag draggable={false}
/> />
<button <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) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation(); // Prevent triggering drag listeners e.stopPropagation(); // Prevent triggering drag listeners
removeImage(productImages.findIndex(img => img.id === image.id)); 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" /> <Trash2 className="h-3.5 w-3.5" />
</button> </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> </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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="p-4"> <div className="p-4">
<h2 className="text-2xl font-semibold mb-2">Add Product Images</h2> <h2 className="text-2xl font-semibold mb-2">Add Product Images</h2>
<p className="text-sm text-muted-foreground mb-2"> <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> </p>
</div> </div>
@@ -691,10 +1121,45 @@ export const ImageUploadStep = <T extends string>({
<Separator /> <Separator />
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 p-4">
<DndContext
sensors={sensors}
collisionDetection={customCollisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
autoScroll={{
threshold: {
x: 0,
y: 0.2, // Start scrolling when dragging near the edges
}
}}
>
{/* Add helper invisible div to handle global styles during drag */}
{activeId && (
<style dangerouslySetInnerHTML={{
__html: `
body {
overflow-anchor: none;
}
img {
-webkit-user-drag: none !important;
}
`
}} />
)}
<div className="space-y-2"> <div className="space-y-2">
{data.map((product: any, index: number) => ( {data.map((product: any, index: number) => (
<Card key={index} className="p-3"> <Card
<CardContent className="p-0"> 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 gap-2">
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4"> <div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3> <h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
@@ -709,15 +1174,14 @@ export const ImageUploadStep = <T extends string>({
<ImageDropzone productIndex={index} /> <ImageDropzone productIndex={index} />
{/* Images appear to the right of the dropzone in a sortable container */} {/* Images appear to the right of the dropzone in a sortable container */}
<div className="flex flex-wrap gap-2 overflow-x-auto flex-1"> <div
<DndContext className={getProductContainerClasses(index)}
sensors={sensors} data-product-id={`product-${index}`}
collisionDetection={closestCenter} id={`product-${index}`}
onDragEnd={(event) => handleDragEnd(event, index)}
> >
<SortableContext <SortableContext
items={getProductImages(index).map(img => img.id)} items={getProductImages(index).map(img => img.id)}
strategy={rectSortingStrategy} strategy={horizontalListSortingStrategy}
> >
{getProductImages(index).map((image, imgIndex) => ( {getProductImages(index).map((image, imgIndex) => (
<SortableImage <SortableImage
@@ -728,7 +1192,6 @@ export const ImageUploadStep = <T extends string>({
/> />
))} ))}
</SortableContext> </SortableContext>
</DndContext>
</div> </div>
{/* Hidden file input for backwards compatibility */} {/* Hidden file input for backwards compatibility */}
@@ -746,6 +1209,22 @@ export const ImageUploadStep = <T extends string>({
</Card> </Card>
))} ))}
</div> </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> </ScrollArea>
<Separator /> <Separator />