Add copy buttons to IDs on image upload and fix upload by URL
This commit is contained in:
@@ -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";
|
||||
@@ -86,6 +86,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
|
||||
@@ -873,7 +876,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,92 +1232,13 @@ 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);
|
||||
|
||||
// Add input reference to maintain focus
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -1327,23 +1251,56 @@ export const ImageUploadStep = <T extends string>({
|
||||
Add from URL
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<PopoverContent
|
||||
className="w-80"
|
||||
onInteractOutside={(e) => {
|
||||
// Prevent closing when interacting with the input
|
||||
if (inputRef.current?.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onOpenAutoFocus={() => {
|
||||
// Focus the input field when the popover opens
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<h4 className="font-medium text-sm">Add image from URL</h4>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Enter image URL"
|
||||
value={urlInputs[productIndex] || ''}
|
||||
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
|
||||
className="text-sm"
|
||||
// Full prevention of event bubbling
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && urlInputs[productIndex]) {
|
||||
e.preventDefault();
|
||||
handleAddImageFromUrl(productIndex, urlInputs[productIndex] || '');
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
// Important for paste operation
|
||||
onPaste={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleAddImageFromUrl(productIndex, urlInputs[productIndex] || '');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{processingUrls[productIndex] ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
||||
@@ -1356,13 +1313,130 @@ export const ImageUploadStep = <T extends string>({
|
||||
);
|
||||
};
|
||||
|
||||
// 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");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 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)}`;
|
||||
|
||||
// Create the new image object with the URL
|
||||
const newImage: ProductImageSortable = {
|
||||
id: imageId,
|
||||
productIndex,
|
||||
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]);
|
||||
|
||||
// Update the product data with the new image URL
|
||||
addImageToProduct(productIndex, validatedUrl);
|
||||
|
||||
// Clear the URL input field on success
|
||||
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
|
||||
|
||||
toast.success(`Image URL added to ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
||||
} catch (error) {
|
||||
console.error('Add image from URL error:', 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 }));
|
||||
};
|
||||
|
||||
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,8 +1496,11 @@ 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">
|
||||
|
||||
Reference in New Issue
Block a user