|
|
|
|
@@ -1,7 +1,7 @@
|
|
|
|
|
import { useCallback, useState, useRef, useEffect } from "react";
|
|
|
|
|
import { useRsi } from "../../hooks/useRsi";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X, Link2 } from "lucide-react";
|
|
|
|
|
import { Loader2, Upload, Trash2, AlertCircle, GripVertical, Maximize2, X, Link2, Copy, Check } from "lucide-react";
|
|
|
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
|
|
|
|
|
type Props<T extends string = string> = {
|
|
|
|
|
data: any[];
|
|
|
|
|
@@ -86,6 +87,9 @@ export const ImageUploadStep = <T extends string>({
|
|
|
|
|
const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
|
|
|
|
|
const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({});
|
|
|
|
|
|
|
|
|
|
// Add state for copy button feedback
|
|
|
|
|
const [copyState, setCopyState] = useState<{[key: string]: boolean}>({});
|
|
|
|
|
|
|
|
|
|
// Initialize product images from data
|
|
|
|
|
const [productImages, setProductImages] = useState<ProductImageSortable[]>(() => {
|
|
|
|
|
// Convert existing product_images to ProductImageSortable objects
|
|
|
|
|
@@ -764,24 +768,31 @@ export const ImageUploadStep = <T extends string>({
|
|
|
|
|
if (!image) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Extract the filename from the URL
|
|
|
|
|
const urlParts = image.imageUrl.split('/');
|
|
|
|
|
const filename = urlParts[urlParts.length - 1];
|
|
|
|
|
// Check if this is an external URL-based image or an uploaded image
|
|
|
|
|
const isExternalUrl = image.imageUrl.startsWith('http') &&
|
|
|
|
|
!image.imageUrl.includes(config.apiUrl.replace(/^https?:\/\//, ''));
|
|
|
|
|
|
|
|
|
|
// Call API to delete the image
|
|
|
|
|
const response = await fetch(`${config.apiUrl}/import/delete-image`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
imageUrl: image.imageUrl,
|
|
|
|
|
filename
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to delete image');
|
|
|
|
|
// Only call the API to delete the file if it's an uploaded image
|
|
|
|
|
if (!isExternalUrl) {
|
|
|
|
|
// Extract the filename from the URL
|
|
|
|
|
const urlParts = image.imageUrl.split('/');
|
|
|
|
|
const filename = urlParts[urlParts.length - 1];
|
|
|
|
|
|
|
|
|
|
// Call API to delete the image
|
|
|
|
|
const response = await fetch(`${config.apiUrl}/import/delete-image`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
imageUrl: image.imageUrl,
|
|
|
|
|
filename
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to delete image');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove the image from our state
|
|
|
|
|
@@ -873,7 +884,7 @@ export const ImageUploadStep = <T extends string>({
|
|
|
|
|
<>
|
|
|
|
|
<Upload className="h-8 w-8 mb-2 text-primary" />
|
|
|
|
|
<p className="text-base text-muted-foreground mb-1">Drop images here or click to select</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">We'll automatically match images to products based on filename</p>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Images dropped here will be automatically assigned to products based on filename</p>
|
|
|
|
|
{unassignedImages.length > 0 && !showUnassigned && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="link"
|
|
|
|
|
@@ -1229,7 +1240,41 @@ export const ImageUploadStep = <T extends string>({
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle adding an image from a URL
|
|
|
|
|
// Add a URL input component that doesn't expand/collapse
|
|
|
|
|
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
|
|
|
|
|
// Use a stable format that won't get affected by DndContext events
|
|
|
|
|
return (
|
|
|
|
|
<form
|
|
|
|
|
className="flex items-center gap-1"
|
|
|
|
|
onSubmit={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (urlInputs[productIndex]) {
|
|
|
|
|
handleAddImageFromUrl(productIndex, urlInputs[productIndex]);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Image URL"
|
|
|
|
|
value={urlInputs[productIndex] || ''}
|
|
|
|
|
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
|
|
|
|
|
className="text-xs h-8 max-w-[180px]"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 whitespace-nowrap"
|
|
|
|
|
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
|
|
|
|
|
>
|
|
|
|
|
{processingUrls[productIndex] ?
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> :
|
|
|
|
|
<Link2 className="h-3.5 w-3.5 mr-1" />}
|
|
|
|
|
Add
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle adding an image from a URL - simplified to skip server
|
|
|
|
|
const handleAddImageFromUrl = async (productIndex: number, url: string) => {
|
|
|
|
|
if (!url || !url.trim()) {
|
|
|
|
|
toast.error("Please enter a valid URL");
|
|
|
|
|
@@ -1240,129 +1285,119 @@ export const ImageUploadStep = <T extends string>({
|
|
|
|
|
// Set processing state
|
|
|
|
|
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
|
|
|
|
|
|
|
|
|
|
// Validate URL format
|
|
|
|
|
let validatedUrl = url.trim();
|
|
|
|
|
|
|
|
|
|
// Add protocol if missing
|
|
|
|
|
if (!validatedUrl.startsWith('http://') && !validatedUrl.startsWith('https://')) {
|
|
|
|
|
validatedUrl = `https://${validatedUrl}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Basic URL validation
|
|
|
|
|
try {
|
|
|
|
|
new URL(validatedUrl);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error("Invalid URL format. Please enter a valid URL");
|
|
|
|
|
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// Create the new image object with the URL
|
|
|
|
|
const newImage: ProductImageSortable = {
|
|
|
|
|
id: imageId,
|
|
|
|
|
productIndex,
|
|
|
|
|
imageUrl: url, // Use the URL directly initially
|
|
|
|
|
loading: true,
|
|
|
|
|
imageUrl: validatedUrl,
|
|
|
|
|
loading: false, // We're not loading from server, so it's ready immediately
|
|
|
|
|
fileName: "From URL"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Add the image directly to the product images list
|
|
|
|
|
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);
|
|
|
|
|
addImageToProduct(productIndex, validatedUrl);
|
|
|
|
|
|
|
|
|
|
// Clear the URL input field on success
|
|
|
|
|
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
|
|
|
|
|
|
|
|
|
|
toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
|
|
|
|
toast.success(`Image URL added to ${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'}`);
|
|
|
|
|
toast.error(`Failed to add image URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
|
|
|
} finally {
|
|
|
|
|
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Function to copy text to clipboard
|
|
|
|
|
const copyToClipboard = (text: string, key: string) => {
|
|
|
|
|
if (!text || text === 'N/A') return;
|
|
|
|
|
|
|
|
|
|
navigator.clipboard.writeText(text)
|
|
|
|
|
.then(() => {
|
|
|
|
|
// Show success state
|
|
|
|
|
setCopyState(prev => ({ ...prev, [key]: true }));
|
|
|
|
|
|
|
|
|
|
// Show toast notification
|
|
|
|
|
toast.success(`Copied: ${text}`);
|
|
|
|
|
|
|
|
|
|
// Reset after 2 seconds
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setCopyState(prev => ({ ...prev, [key]: false }));
|
|
|
|
|
}, 2000);
|
|
|
|
|
})
|
|
|
|
|
.catch(err => {
|
|
|
|
|
console.error('Failed to copy:', err);
|
|
|
|
|
toast.error('Failed to copy to clipboard');
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Small reusable copy button component
|
|
|
|
|
const CopyButton = ({ text, itemKey }: { text: string, itemKey: string }) => {
|
|
|
|
|
const isSuccess = copyState[itemKey];
|
|
|
|
|
const canCopy = text && text !== 'N/A';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
copyToClipboard(text, itemKey);
|
|
|
|
|
}}
|
|
|
|
|
className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${
|
|
|
|
|
canCopy
|
|
|
|
|
? isSuccess
|
|
|
|
|
? "bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400"
|
|
|
|
|
: "text-muted-foreground hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
|
|
|
: "opacity-50 cursor-not-allowed"
|
|
|
|
|
}`}
|
|
|
|
|
disabled={!canCopy}
|
|
|
|
|
title={canCopy ? "Copy to clipboard" : "Nothing to copy"}
|
|
|
|
|
>
|
|
|
|
|
{isSuccess ? (
|
|
|
|
|
<Check className="h-3 w-3" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="h-3 w-3" />
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
|
|
|
|
{/* Header - fixed at top */}
|
|
|
|
|
<div className="px-8 py-6 bg-background shrink-0">
|
|
|
|
|
<h2 className="text-2xl font-semibold">Add Product Images</h2>
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
|
|
|
Upload images for each product or add them from URLs. Drag images to reorder them or move them between products.
|
|
|
|
|
Drag images to reorder them or move them between products.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@@ -1422,12 +1457,42 @@ export const ImageUploadStep = <T extends string>({
|
|
|
|
|
<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'}
|
|
|
|
|
<span className="font-medium">UPC:</span> {product.upc || 'N/A'}
|
|
|
|
|
<CopyButton text={product.upc || ''} itemKey={`upc-${index}`} />
|
|
|
|
|
{' | '}
|
|
|
|
|
<span className="font-medium">Supplier #:</span> {product.supplier_no || 'N/A'}
|
|
|
|
|
<CopyButton text={product.supplier_no || ''} itemKey={`supplier-${index}`} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-shrink-0">
|
|
|
|
|
<ImageUrlInput productIndex={index} />
|
|
|
|
|
<form
|
|
|
|
|
className="flex items-center gap-2"
|
|
|
|
|
onSubmit={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (urlInputs[index]) {
|
|
|
|
|
handleAddImageFromUrl(index, urlInputs[index]);
|
|
|
|
|
updateUrlInput(index, '');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Image URL"
|
|
|
|
|
value={urlInputs[index] || ''}
|
|
|
|
|
onChange={(e) => updateUrlInput(index, e.target.value)}
|
|
|
|
|
className="text-xs h-8 w-[180px]"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 whitespace-nowrap flex gap-1 items-center text-xs"
|
|
|
|
|
disabled={processingUrls[index] || !urlInputs[index]}
|
|
|
|
|
>
|
|
|
|
|
{processingUrls[index] ?
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> :
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />}
|
|
|
|
|
Add Image
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|