Move image from URL option from validate step to add images step

This commit is contained in:
2025-02-27 01:16:01 -05:00
parent bb455b3c37
commit a19a8ba412
2 changed files with 256 additions and 73 deletions

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, Maximize2, X } from "lucide-react"; import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X, Link2 } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -37,6 +37,11 @@ import {
DialogContent, DialogContent,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type Props<T extends string = string> = { type Props<T extends string = string> = {
data: any[]; data: any[];
@@ -70,7 +75,6 @@ export const ImageUploadStep = <T extends string>({
}: Props<T>) => { }: Props<T>) => {
const { translations } = useRsi<T>(); const { translations } = useRsi<T>();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [productImages, setProductImages] = useState<ProductImageSortable[]>([]);
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({}); const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]); const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
const [processingBulk, setProcessingBulk] = useState(false); const [processingBulk, setProcessingBulk] = useState(false);
@@ -78,6 +82,49 @@ export const ImageUploadStep = <T extends string>({
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [activeImage, setActiveImage] = useState<ProductImageSortable | null>(null); const [activeImage, setActiveImage] = useState<ProductImageSortable | null>(null);
// Add state for URL input
const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({});
// Initialize product images from data
const [productImages, setProductImages] = useState<ProductImageSortable[]>(() => {
// Convert existing product_images to ProductImageSortable objects
const initialImages: ProductImageSortable[] = [];
data.forEach((product, productIndex) => {
if (product.product_images) {
let imageUrls: string[] = [];
// Handle different formats of product_images
if (typeof product.product_images === 'string') {
// Split by comma if it's a string
imageUrls = product.product_images.split(',').filter(Boolean);
} else if (Array.isArray(product.product_images)) {
// Use the array directly
imageUrls = product.product_images.filter(Boolean);
} else if (product.product_images) {
// Handle case where it might be a single value
imageUrls = [String(product.product_images)];
}
// Create ProductImageSortable objects for each URL
imageUrls.forEach((url, i) => {
if (url && url.trim()) {
initialImages.push({
id: `image-${productIndex}-initial-${i}`,
productIndex,
imageUrl: url.trim(),
loading: false,
fileName: `Image ${i + 1}`
});
}
});
}
});
return initialImages;
});
// Set up sensors for drag and drop with enhanced configuration // Set up sensors for drag and drop with enhanced configuration
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -245,23 +292,52 @@ export const ImageUploadStep = <T extends string>({
// Get the current product // Get the current product
const product = newData[productIndex]; const product = newData[productIndex];
// Get current image URLs // We need to update product_images array directly instead of the image_url field
let currentUrls = product.image_url ? if (!product.product_images) {
(typeof product.image_url === 'string' ? product.image_url.split(',') : product.image_url) product.product_images = [];
: []; } else if (typeof product.product_images === 'string') {
// Handle case where it might be a comma-separated string
product.product_images = product.product_images.split(',').filter(Boolean);
}
// Filter out all instances of the URL we're removing // Filter out the image URL we're removing
currentUrls = currentUrls.filter((url: string) => url && url !== imageUrl); if (Array.isArray(product.product_images)) {
product.product_images = product.product_images.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; return newData;
}; };
// Handle drag end event to reorder or reassign images // 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];
// Initialize product_images array if it doesn't exist
if (!product.product_images) {
product.product_images = [];
} else if (typeof product.product_images === 'string') {
// Handle case where it might be a comma-separated string
product.product_images = product.product_images.split(',').filter(Boolean);
}
// Ensure it's an array
if (!Array.isArray(product.product_images)) {
product.product_images = [product.product_images].filter(Boolean);
}
// Only add if the URL doesn't already exist
if (!product.product_images.includes(imageUrl)) {
product.product_images.push(imageUrl);
}
return newData;
};
// Update handleDragEnd to work with the updated product data structure
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -358,7 +434,7 @@ export const ImageUploadStep = <T extends string>({
targetImagesAfter: filteredItems.filter(item => item.productIndex === targetProductIndex).length targetImagesAfter: filteredItems.filter(item => item.productIndex === targetProductIndex).length
}); });
// Update both products' image_url fields - creating new objects to ensure state updates // Update both products' image data fields
let updatedData = [...data]; // Start with a fresh copy let updatedData = [...data]; // Start with a fresh copy
// First remove from source // First remove from source
@@ -406,7 +482,7 @@ export const ImageUploadStep = <T extends string>({
newItems.push(...newFilteredItems); newItems.push(...newFilteredItems);
// Update the product data with the new image order // Update the product data with the new image order
// Since we're just reordering, the URLs don't change, but their order might matter
return newItems; return newItems;
} }
@@ -427,7 +503,7 @@ export const ImageUploadStep = <T extends string>({
newItems.push(...newFilteredItems); newItems.push(...newFilteredItems);
// Update the product data with the new image order // Update the product data with the new image order
// The order might matter for display purposes
return newItems; return newItems;
}); });
} }
@@ -437,45 +513,7 @@ export const ImageUploadStep = <T extends string>({
setActiveImage(null); setActiveImage(null);
}; };
// Function to add an image URL to a product // Function to handle image upload - update product data
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 handle image upload
const handleImageUpload = async (files: FileList | File[], productIndex: number) => { const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
@@ -523,6 +561,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
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) {
@@ -719,7 +758,7 @@ export const ImageUploadStep = <T extends string>({
}; };
}, []); }, []);
// Function to remove an image // Function to remove an image - update to work with product_images
const removeImage = async (imageIndex: number) => { const removeImage = async (imageIndex: number) => {
const image = productImages[imageIndex]; const image = productImages[imageIndex];
if (!image) return; if (!image) return;
@@ -749,6 +788,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
removeImageFromProduct(image.productIndex, image.imageUrl);
toast.success('Image removed successfully'); toast.success('Image removed successfully');
} catch (error) { } catch (error) {
@@ -761,14 +801,30 @@ export const ImageUploadStep = <T extends string>({
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await onSubmit(data, file); // First, we need to ensure product_images is properly formatted for each product
const updatedData = [...data].map((product, index) => {
// Get all images for this product
const images = productImages
.filter(img => img.productIndex === index)
.map(img => img.imageUrl)
.filter(Boolean);
// Update the product with the formatted image URLs
return {
...product,
// Store as comma-separated string to ensure compatibility
product_images: images.join(',')
};
});
await onSubmit(updatedData, file);
} catch (error) { } catch (error) {
console.error('Submit error:', error); console.error('Submit error:', error);
toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`); toast.error(`Failed to submit: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [data, file, onSubmit]); }, [data, file, onSubmit, productImages]);
// Function to ensure URLs are properly formatted with absolute paths // Function to ensure URLs are properly formatted with absolute paths
const getFullImageUrl = (url: string): string => { const getFullImageUrl = (url: string): string => {
@@ -1173,13 +1229,140 @@ export const ImageUploadStep = <T extends string>({
); );
}; };
// Handle adding an image from a URL
const handleAddImageFromUrl = async (productIndex: number, url: string) => {
if (!url || !url.trim()) {
toast.error("Please enter a valid URL");
return;
}
try {
// Set processing state
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
// Create a unique ID for this image
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Add a placeholder for this image while it's being processed
const newImage: ProductImageSortable = {
id: imageId,
productIndex,
imageUrl: url, // Use the URL directly initially
loading: true,
fileName: "From URL"
};
setProductImages(prev => [...prev, newImage]);
// Call the API to validate and potentially process the URL
const response = await fetch(`${config.apiUrl}/import/add-image-from-url`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: url,
productIndex: productIndex,
upc: data[productIndex].upc || '',
supplier_no: data[productIndex].supplier_no || ''
}),
});
if (!response.ok) {
throw new Error('Failed to add image from URL');
}
const result = await response.json();
// Update the image URL in our state
setProductImages(prev =>
prev.map(img =>
img.id === imageId
? { ...img, imageUrl: result.imageUrl || url, loading: false }
: img
)
);
// Update the product data with the new image URL
addImageToProduct(productIndex, result.imageUrl || url);
// Clear the URL input field on success
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
} catch (error) {
console.error('Add image from URL error:', error);
// Remove the failed image from our state
setProductImages(prev =>
prev.filter(img =>
!(img.loading && img.productIndex === productIndex && img.fileName === "From URL")
)
);
toast.error(`Failed to add image from URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
}
};
// Update the URL input value
const updateUrlInput = (productIndex: number, value: string) => {
setUrlInputs(prev => ({ ...prev, [productIndex]: value }));
};
// Add a URL input component
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
className="h-8 flex gap-1 items-center text-xs whitespace-nowrap"
>
<Link2 className="h-4 w-4" />
Add from URL
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h4 className="font-medium text-sm">Add image from URL</h4>
<div className="flex gap-2">
<Input
placeholder="Enter image URL"
value={urlInputs[productIndex] || ''}
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
className="text-sm"
/>
<Button
size="sm"
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
onClick={() => {
handleAddImageFromUrl(productIndex, urlInputs[productIndex] || '');
setIsOpen(false);
}}
>
{processingUrls[productIndex] ?
<Loader2 className="h-4 w-4 animate-spin" /> :
"Add"}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
return ( return (
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
{/* Header - fixed at top */} {/* Header - fixed at top */}
<div className="px-8 py-6 bg-background shrink-0"> <div className="px-8 py-6 bg-background shrink-0">
<h2 className="text-2xl font-semibold">Add Product Images</h2> <h2 className="text-2xl font-semibold">Add Product Images</h2>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Upload images for each product. Drag images to reorder them or move them between products. Upload images for each product or add them from URLs. Drag images to reorder them or move them between products.
</p> </p>
</div> </div>
@@ -1235,6 +1418,7 @@ export const ImageUploadStep = <T extends string>({
> >
<CardContent className="p-0"> <CardContent 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-center justify-between 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>
<div className="text-xs lg:text-sm text-muted-foreground"> <div className="text-xs lg:text-sm text-muted-foreground">
@@ -1242,9 +1426,15 @@ export const ImageUploadStep = <T extends string>({
<span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'} <span className="font-medium"> Supplier #:</span> {product.supplier_no || 'N/A'}
</div> </div>
</div> </div>
<div className="flex-shrink-0">
<ImageUrlInput productIndex={index} />
</div>
</div>
<div className="flex items-start gap-2"> <div className="flex flex-col sm:flex-row items-start gap-2">
<div className="flex flex-row gap-2 items-start">
<ImageDropzone productIndex={index} /> <ImageDropzone productIndex={index} />
</div>
<div <div
className={getProductContainerClasses(index)} className={getProductContainerClasses(index)}

View File

@@ -84,13 +84,6 @@ const BASE_IMPORT_FIELDS = [
{ rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" },
], ],
}, },
{
label: "Image URL",
key: "image_url",
description: "Product image URL(s)",
fieldType: { type: "multi-input" },
width: 300,
},
{ {
label: "MSRP", label: "MSRP",
key: "msrp", key: "msrp",