Add copy buttons to IDs on image upload and fix upload by URL
This commit is contained in:
12
inventory-server/package-lock.json
generated
12
inventory-server/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/diff": "^7.0.1",
|
"@types/diff": "^7.0.1",
|
||||||
|
"axios": "^1.8.1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
@@ -629,6 +630,17 @@
|
|||||||
"node": ">= 6.0.0"
|
"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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/diff": "^7.0.1",
|
"@types/diff": "^7.0.1",
|
||||||
|
"axios": "^1.8.1",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parse": "^5.6.0",
|
"csv-parse": "^5.6.0",
|
||||||
|
|||||||
@@ -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, 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 { 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";
|
||||||
@@ -86,6 +86,9 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
|
const [urlInputs, setUrlInputs] = useState<{ [key: number]: string }>({});
|
||||||
const [processingUrls, setProcessingUrls] = useState<{ [key: number]: boolean }>({});
|
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
|
// Initialize product images from data
|
||||||
const [productImages, setProductImages] = useState<ProductImageSortable[]>(() => {
|
const [productImages, setProductImages] = useState<ProductImageSortable[]>(() => {
|
||||||
// Convert existing product_images to ProductImageSortable objects
|
// 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" />
|
<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-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 && (
|
{unassignedImages.length > 0 && !showUnassigned && (
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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
|
// Add a URL input component
|
||||||
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
|
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Add input reference to maintain focus
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -1327,23 +1251,56 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
Add from URL
|
Add from URL
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80">
|
<PopoverContent
|
||||||
<div className="space-y-2">
|
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>
|
<h4 className="font-medium text-sm">Add image from URL</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
placeholder="Enter image URL"
|
placeholder="Enter image URL"
|
||||||
value={urlInputs[productIndex] || ''}
|
value={urlInputs[productIndex] || ''}
|
||||||
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
|
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
|
||||||
className="text-sm"
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
|
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
handleAddImageFromUrl(productIndex, urlInputs[productIndex] || '');
|
handleAddImageFromUrl(productIndex, urlInputs[productIndex] || '');
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{processingUrls[productIndex] ?
|
{processingUrls[productIndex] ?
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> :
|
<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 (
|
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 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>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
||||||
<span className="font-medium">UPC:</span> {product.upc || 'N/A'} |
|
<span className="font-medium">UPC:</span> {product.upc || 'N/A'}
|
||||||
<span className="font-medium"> Supplier #:</span> {product.supplier_no || '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>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
|
|
||||||
#Clear previous mount in case it’s still there
|
#Clear previous mount in case it’s still there
|
||||||
umount '/Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server'
|
umount '/Users/matt/Dev/inventory/inventory-server'
|
||||||
|
|
||||||
#Mount
|
#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/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/'
|
|
||||||
Reference in New Issue
Block a user