Add copy buttons to IDs on image upload and fix upload by URL

This commit is contained in:
2025-02-27 10:48:33 -05:00
parent a19a8ba412
commit c1159f518c
4 changed files with 182 additions and 93 deletions

View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@types/diff": "^7.0.1",
"axios": "^1.8.1",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
@@ -629,6 +630,17 @@
"node": ">= 6.0.0"
}
},
"node_modules/axios": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",

View File

@@ -19,6 +19,7 @@
"license": "ISC",
"dependencies": {
"@types/diff": "^7.0.1",
"axios": "^1.8.1",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",

View File

@@ -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">

View File

@@ -1,8 +1,7 @@
#!/bin/zsh
#Clear previous mount in case its still there
umount '/Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server'
umount '/Users/matt/Dev/inventory/inventory-server'
#Mount
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server/'
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server/'
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Dev/inventory/inventory-server/'