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');
|
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,
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
// 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 => {
|
setProductImages(items => {
|
||||||
// Filter to get only the images for this product
|
// Remove the image from its current product
|
||||||
const productFilteredItems = items.filter(item => item.productIndex === productIndex);
|
const filteredItems = items.filter(item => item.id !== activeId);
|
||||||
|
|
||||||
// Find the indices within this filtered list
|
// Add the image to the target product
|
||||||
const oldIndex = productFilteredItems.findIndex(item => item.id === active.id);
|
filteredItems.push(newImage);
|
||||||
const newIndex = productFilteredItems.findIndex(item => item.id === over.id);
|
|
||||||
|
|
||||||
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
|
// First remove from source
|
||||||
const newFilteredItems = arrayMove(productFilteredItems, oldIndex, newIndex);
|
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
|
||||||
|
|
||||||
// Create a new full list replacing the items for this product with the reordered ones
|
// Then add to target
|
||||||
const newItems = items.filter(item => item.productIndex !== productIndex);
|
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
|
||||||
newItems.push(...newFilteredItems);
|
|
||||||
|
|
||||||
// Update the product data with the new image order
|
// Show notification
|
||||||
updateProductImageOrder(productIndex, newFilteredItems);
|
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
|
// 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,33 +1121,67 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<ScrollArea className="flex-1 p-4">
|
<ScrollArea className="flex-1 p-4">
|
||||||
<div className="space-y-2">
|
<DndContext
|
||||||
{data.map((product: any, index: number) => (
|
sensors={sensors}
|
||||||
<Card key={index} className="p-3">
|
collisionDetection={customCollisionDetection}
|
||||||
<CardContent className="p-0">
|
onDragStart={handleDragStart}
|
||||||
<div className="flex flex-col gap-2">
|
onDragOver={handleDragOver}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-baseline gap-0.5 lg:gap-4">
|
onDragEnd={handleDragEnd}
|
||||||
<h3 className="text-base font-medium">{product.name || `Product #${index + 1}`}</h3>
|
autoScroll={{
|
||||||
<div className="text-xs lg:text-sm text-muted-foreground">
|
threshold: {
|
||||||
<span className="font-medium">UPC:</span> {product.upc || 'N/A'} |
|
x: 0,
|
||||||
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
|
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>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{/* Dropzone for image upload always on the left */}
|
{/* Dropzone for image upload always on the left */}
|
||||||
<ImageDropzone productIndex={index} />
|
<ImageDropzone productIndex={index} />
|
||||||
|
|
||||||
{/* 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,24 +1192,39 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden file input for backwards compatibility */}
|
{/* Hidden file input for backwards compatibility */}
|
||||||
<Input
|
<Input
|
||||||
ref={el => fileInputRefs.current[index] = el}
|
ref={el => fileInputRefs.current[index] = el}
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
multiple
|
multiple
|
||||||
onChange={(e) => e.target.files && handleImageUpload(e.target.files, index)}
|
onChange={(e) => e.target.files && handleImageUpload(e.target.files, index)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user