Deal with webp images on import
This commit is contained in:
@@ -7,6 +7,8 @@ const path = require('path');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fsp = fs.promises;
|
const fsp = fs.promises;
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
const axios = require('axios');
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
// Create uploads directory if it doesn't exist
|
// Create uploads directory if it doesn't exist
|
||||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
||||||
@@ -152,6 +154,78 @@ cleanupImagesOnStartup();
|
|||||||
|
|
||||||
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
|
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
|
||||||
|
|
||||||
|
const getExtensionForImage = (mimetype, sourceName = '') => {
|
||||||
|
const sourceExt = path.extname(sourceName).toLowerCase();
|
||||||
|
if (sourceExt) return sourceExt;
|
||||||
|
|
||||||
|
switch (mimetype) {
|
||||||
|
case 'image/jpeg': return '.jpg';
|
||||||
|
case 'image/png': return '.png';
|
||||||
|
case 'image/gif': return '.gif';
|
||||||
|
case 'image/webp': return '.webp';
|
||||||
|
case 'image/tiff': return '.tif';
|
||||||
|
default: return '.jpg';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUploadFilename = (prefix, extension) => {
|
||||||
|
const safePrefix = String(prefix || 'product').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80) || 'product';
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
return `${safePrefix}-${uniqueSuffix}${extension}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUploadedImageResponse = (reqFile, processingResult, filePath) => {
|
||||||
|
reqFile.size = processingResult.finalSize;
|
||||||
|
|
||||||
|
const effectivePath = processingResult.newFilePath || filePath;
|
||||||
|
if (processingResult.newFilePath) {
|
||||||
|
reqFile.filename = path.basename(processingResult.newFilePath);
|
||||||
|
reqFile.mimetype = 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = 'https://tools.acherryontop.com';
|
||||||
|
const imageUrl = `${baseUrl}/uploads/products/${reqFile.filename}`;
|
||||||
|
|
||||||
|
scheduleImageDeletion(reqFile.filename, effectivePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
imageUrl,
|
||||||
|
fileName: reqFile.filename,
|
||||||
|
mimetype: reqFile.mimetype,
|
||||||
|
fullPath: effectivePath,
|
||||||
|
notices: processingResult.notices,
|
||||||
|
warnings: processingResult.warnings,
|
||||||
|
metadata: processingResult.metadata,
|
||||||
|
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isBlockedImageSourceHost = (hostname) => {
|
||||||
|
const host = String(hostname || '').toLowerCase();
|
||||||
|
if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipVersion = net.isIP(host);
|
||||||
|
if (ipVersion === 4) {
|
||||||
|
const [first, second] = host.split('.').map(Number);
|
||||||
|
return (
|
||||||
|
first === 10 ||
|
||||||
|
first === 127 ||
|
||||||
|
(first === 169 && second === 254) ||
|
||||||
|
(first === 172 && second >= 16 && second <= 31) ||
|
||||||
|
(first === 192 && second === 168)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipVersion === 6) {
|
||||||
|
return host === '::1' || host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80');
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const processUploadedImage = async (filePath, mimetype) => {
|
const processUploadedImage = async (filePath, mimetype) => {
|
||||||
const notices = [];
|
const notices = [];
|
||||||
const legacyWarnings = [];
|
const legacyWarnings = [];
|
||||||
@@ -211,13 +285,19 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TIFF: convert to JPEG (don't store TIFF files)
|
// Convert unsupported product image storage formats to JPEG.
|
||||||
let convertedFromTiff = false;
|
let convertedToJpegFrom = null;
|
||||||
if (format === 'tiff') {
|
if (format === 'tiff' || format === 'webp') {
|
||||||
convertedFromTiff = true;
|
convertedToJpegFrom = format;
|
||||||
format = 'jpeg';
|
format = 'jpeg';
|
||||||
const message = 'Converted from TIFF to JPEG.';
|
const sourceLabel = convertedToJpegFrom === 'tiff' ? 'TIFF' : 'WebP';
|
||||||
notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' });
|
const message = `Converted from ${sourceLabel} to JPEG.`;
|
||||||
|
notices.push({
|
||||||
|
message,
|
||||||
|
level: 'info',
|
||||||
|
code: `converted_from_${convertedToJpegFrom}`,
|
||||||
|
source: 'server'
|
||||||
|
});
|
||||||
legacyWarnings.push(message);
|
legacyWarnings.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +433,7 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
});
|
});
|
||||||
legacyWarnings.push(message);
|
legacyWarnings.push(message);
|
||||||
}
|
}
|
||||||
} else if (shouldConvertToRgb || convertedFromTiff) {
|
} else if (shouldConvertToRgb || convertedToJpegFrom) {
|
||||||
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
|
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
|
||||||
mutated = true;
|
mutated = true;
|
||||||
finalBuffer = data;
|
finalBuffer = data;
|
||||||
@@ -373,10 +453,10 @@ const processUploadedImage = async (filePath, mimetype) => {
|
|||||||
metadata.optimizedSize = metadata.size;
|
metadata.optimizedSize = metadata.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename TIFF files to .jpg after conversion
|
// Rename converted source files to .jpg after conversion
|
||||||
let newFilePath = null;
|
let newFilePath = null;
|
||||||
if (convertedFromTiff) {
|
if (convertedToJpegFrom) {
|
||||||
newFilePath = filePath.replace(/\.tiff?$/i, '.jpg');
|
newFilePath = filePath.replace(/\.(tiff?|webp)$/i, '.jpg');
|
||||||
if (newFilePath !== filePath) {
|
if (newFilePath !== filePath) {
|
||||||
await fsp.rename(filePath, newFilePath);
|
await fsp.rename(filePath, newFilePath);
|
||||||
}
|
}
|
||||||
@@ -426,25 +506,8 @@ const storage = multer.diskStorage({
|
|||||||
cb(null, uploadsDir);
|
cb(null, uploadsDir);
|
||||||
},
|
},
|
||||||
filename: function (req, file, cb) {
|
filename: function (req, file, cb) {
|
||||||
// Create unique filename with original extension
|
const fileExt = getExtensionForImage(file.mimetype, file.originalname);
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
const fileName = createUploadFilename(req.body.upc || 'product', fileExt);
|
||||||
|
|
||||||
// Make sure we preserve the original file extension
|
|
||||||
let fileExt = path.extname(file.originalname).toLowerCase();
|
|
||||||
|
|
||||||
// Ensure there is a proper extension based on mimetype if none exists
|
|
||||||
if (!fileExt) {
|
|
||||||
switch (file.mimetype) {
|
|
||||||
case 'image/jpeg': fileExt = '.jpg'; break;
|
|
||||||
case 'image/png': fileExt = '.png'; break;
|
|
||||||
case 'image/gif': fileExt = '.gif'; break;
|
|
||||||
case 'image/webp': fileExt = '.webp'; break;
|
|
||||||
case 'image/tiff': fileExt = '.tif'; break;
|
|
||||||
default: fileExt = '.jpg'; // Default to jpg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`;
|
|
||||||
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
|
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
|
||||||
cb(null, fileName);
|
cb(null, fileName);
|
||||||
}
|
}
|
||||||
@@ -652,34 +715,10 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
|
|||||||
|
|
||||||
// Process the image (resize/compress/color-space) before responding
|
// Process the image (resize/compress/color-space) before responding
|
||||||
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
||||||
req.file.size = processingResult.finalSize;
|
const responsePayload = buildUploadedImageResponse(req.file, processingResult, filePath);
|
||||||
|
|
||||||
// If TIFF was converted to JPG, update filename to match the renamed file
|
|
||||||
const effectivePath = processingResult.newFilePath || filePath;
|
|
||||||
if (processingResult.newFilePath) {
|
|
||||||
req.file.filename = path.basename(processingResult.newFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create URL for the uploaded file - using an absolute URL with domain
|
|
||||||
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
|
|
||||||
const baseUrl = 'https://tools.acherryontop.com';
|
|
||||||
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
|
||||||
|
|
||||||
// Schedule this image for deletion in 24 hours
|
|
||||||
scheduleImageDeletion(req.file.filename, effectivePath);
|
|
||||||
|
|
||||||
// Return success response with image URL
|
// Return success response with image URL
|
||||||
res.status(200).json({
|
res.status(200).json(responsePayload);
|
||||||
success: true,
|
|
||||||
imageUrl,
|
|
||||||
fileName: req.file.filename,
|
|
||||||
mimetype: req.file.mimetype,
|
|
||||||
fullPath: filePath,
|
|
||||||
notices: processingResult.notices,
|
|
||||||
warnings: processingResult.warnings,
|
|
||||||
metadata: processingResult.metadata,
|
|
||||||
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading image:', error);
|
console.error('Error uploading image:', error);
|
||||||
@@ -697,6 +736,89 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/upload-image-url', async (req, res) => {
|
||||||
|
let filePath = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { imageUrl, upc, supplier_no } = req.body || {};
|
||||||
|
if (!imageUrl || typeof imageUrl !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'imageUrl is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(imageUrl.trim());
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid image URL' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||||
|
return res.status(400).json({ error: 'Image URL must use http or https' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBlockedImageSourceHost(parsedUrl.hostname)) {
|
||||||
|
return res.status(400).json({ error: 'Image URL host is not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(parsedUrl.toString(), {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 15000,
|
||||||
|
maxContentLength: 15 * 1024 * 1024,
|
||||||
|
maxBodyLength: 15 * 1024 * 1024,
|
||||||
|
headers: {
|
||||||
|
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
'User-Agent': 'inventory-image-import/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = String(response.headers['content-type'] || '').split(';')[0].toLowerCase();
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
return res.status(400).json({ error: 'URL did not return an image' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageBuffer = Buffer.from(response.data);
|
||||||
|
if (!imageBuffer.length) {
|
||||||
|
return res.status(400).json({ error: 'Downloaded image was empty' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExt = getExtensionForImage(contentType, parsedUrl.pathname);
|
||||||
|
const filename = createUploadFilename(upc || supplier_no || 'product-url', fileExt);
|
||||||
|
filePath = path.join(uploadsDir, filename);
|
||||||
|
await fsp.writeFile(filePath, imageBuffer);
|
||||||
|
|
||||||
|
const reqFile = {
|
||||||
|
filename,
|
||||||
|
originalname: path.basename(parsedUrl.pathname) || filename,
|
||||||
|
mimetype: contentType,
|
||||||
|
size: imageBuffer.length,
|
||||||
|
path: filePath
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Image URL downloaded:', {
|
||||||
|
filename: reqFile.filename,
|
||||||
|
originalUrl: parsedUrl.toString(),
|
||||||
|
mimetype: reqFile.mimetype,
|
||||||
|
size: reqFile.size,
|
||||||
|
path: reqFile.path
|
||||||
|
});
|
||||||
|
|
||||||
|
const processingResult = await processUploadedImage(filePath, reqFile.mimetype);
|
||||||
|
const responsePayload = buildUploadedImageResponse(reqFile, processingResult, filePath);
|
||||||
|
|
||||||
|
res.status(200).json(responsePayload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading image from URL:', error);
|
||||||
|
if (filePath && fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Failed to remove URL-downloaded file after processing error:', cleanupError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to upload image from URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Image deletion endpoint
|
// Image deletion endpoint
|
||||||
router.delete('/delete-image', (req, res) => {
|
router.delete('/delete-image', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -93,19 +93,9 @@ export function getFieldValue(product: SearchProduct, field: BulkEditFieldChoice
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the backend field key for submission */
|
/** Get the product editor API field key for submission */
|
||||||
export function getSubmitFieldKey(field: BulkEditFieldChoice): string {
|
export function getSubmitFieldKey(field: BulkEditFieldChoice): string {
|
||||||
switch (field) {
|
return field;
|
||||||
case "name": return "description"; // backend field is "description" for product name
|
|
||||||
case "description": return "notes"; // backend uses "notes" for product description
|
|
||||||
case "hts_code": return "harmonized_tariff_code";
|
|
||||||
case "msrp": return "sellingprice";
|
|
||||||
case "cost_each": return "cost_each";
|
|
||||||
case "tax_cat": return "tax_code";
|
|
||||||
case "size_cat": return "size_cat";
|
|
||||||
case "ship_restrictions": return "shipping_restrictions";
|
|
||||||
default: return field;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BulkEditRowProps {
|
interface BulkEditRowProps {
|
||||||
|
|||||||
@@ -188,6 +188,24 @@ function SortableImageCell({
|
|||||||
|
|
||||||
let newImageCounter = 0;
|
let newImageCounter = 0;
|
||||||
|
|
||||||
|
const normalizeImageUrlInput = (url: string) => {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLikelyWebpUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(normalizeImageUrlInput(url));
|
||||||
|
return parsed.pathname.toLowerCase().endsWith(".webp");
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase().includes(".webp");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function ImageManager({
|
export function ImageManager({
|
||||||
images,
|
images,
|
||||||
setImages,
|
setImages,
|
||||||
@@ -301,12 +319,36 @@ export function ImageManager({
|
|||||||
[addNewImage]
|
[addNewImage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUrlAdd = useCallback(() => {
|
const handleUrlAdd = useCallback(async () => {
|
||||||
const url = urlInput.trim();
|
const url = urlInput.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
addNewImage(url);
|
|
||||||
setUrlInput("");
|
if (!isLikelyWebpUrl(url)) {
|
||||||
|
addNewImage(url);
|
||||||
|
setUrlInput("");
|
||||||
|
setAddOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
setAddOpen(false);
|
setAddOpen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post("/api/import/upload-image-url", {
|
||||||
|
imageUrl: normalizeImageUrlInput(url),
|
||||||
|
});
|
||||||
|
if (res.data?.imageUrl) {
|
||||||
|
addNewImage(res.data.imageUrl);
|
||||||
|
setUrlInput("");
|
||||||
|
} else {
|
||||||
|
throw new Error("Upload response did not include an image URL");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to convert WebP image URL:", error);
|
||||||
|
toast.error(`Failed to convert WebP image URL: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
}, [urlInput, addNewImage]);
|
}, [urlInput, addNewImage]);
|
||||||
|
|
||||||
const activeImage = activeId
|
const activeImage = activeId
|
||||||
|
|||||||
@@ -344,8 +344,10 @@ export function ProductEditForm({
|
|||||||
|
|
||||||
const originalIds = original.map((img) => img.iid);
|
const originalIds = original.map((img) => img.iid);
|
||||||
const currentIds = current.map((img) => img.iid);
|
const currentIds = current.map((img) => img.iid);
|
||||||
|
const originalExistingIds = originalIds.filter((id): id is number => typeof id === "number");
|
||||||
|
const currentExistingIds = currentIds.filter((id): id is number => typeof id === "number");
|
||||||
|
|
||||||
const toDelete = originalIds.filter((id) => !currentIds.includes(id)) as number[];
|
const toDelete = originalExistingIds.filter((id) => !currentIds.includes(id));
|
||||||
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||||
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
|
||||||
const add: Record<string, string> = {};
|
const add: Record<string, string> = {};
|
||||||
@@ -357,8 +359,11 @@ export function ProductEditForm({
|
|||||||
|
|
||||||
const order = current.map((img) => img.iid);
|
const order = current.map((img) => img.iid);
|
||||||
|
|
||||||
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
|
const originalHidden = original
|
||||||
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
|
.filter((img) => img.hidden)
|
||||||
|
.map((img) => img.iid)
|
||||||
|
.filter((id): id is number => typeof id === "number");
|
||||||
|
const orderChanged = JSON.stringify(currentExistingIds) !== JSON.stringify(originalExistingIds);
|
||||||
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
|
||||||
const hasDeleted = toDelete.length > 0;
|
const hasDeleted = toDelete.length > 0;
|
||||||
const hasAdded = Object.keys(add).length > 0;
|
const hasAdded = Object.keys(add).length > 0;
|
||||||
@@ -492,7 +497,22 @@ export function ProductEditForm({
|
|||||||
} else {
|
} else {
|
||||||
toast.success("Product updated successfully");
|
toast.success("Product updated successfully");
|
||||||
originalValuesRef.current = { ...data };
|
originalValuesRef.current = { ...data };
|
||||||
originalImagesRef.current = [...productImages];
|
if (imageChanges) {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/import/product-images/${product.pid}`);
|
||||||
|
originalImagesRef.current = res.data;
|
||||||
|
setProductImages(res.data);
|
||||||
|
} catch {
|
||||||
|
const submittedImages = productImages.map((img) => ({
|
||||||
|
...img,
|
||||||
|
isNew: false,
|
||||||
|
}));
|
||||||
|
originalImagesRef.current = submittedImages;
|
||||||
|
setProductImages(submittedImages);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
originalImagesRef.current = [...productImages];
|
||||||
|
}
|
||||||
reset(data);
|
reset(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+42
-4
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import config from "@/config";
|
||||||
import { Product, ProductImageSortable } from "../types";
|
import { Product, ProductImageSortable } from "../types";
|
||||||
|
|
||||||
type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[];
|
type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[];
|
||||||
|
|
||||||
|
const isLikelyWebpUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
return new URL(url).pathname.toLowerCase().endsWith(".webp");
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase().includes(".webp");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface UseUrlImageUploadProps {
|
interface UseUrlImageUploadProps {
|
||||||
data: Product[];
|
data: Product[];
|
||||||
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
|
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
|
||||||
@@ -46,6 +55,35 @@ export const useUrlImageUpload = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let imageUrl = validatedUrl;
|
||||||
|
let fileName = "From URL";
|
||||||
|
|
||||||
|
if (isLikelyWebpUrl(validatedUrl)) {
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/upload-image-url`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageUrl: validatedUrl,
|
||||||
|
upc: data[productIndex].upc || "",
|
||||||
|
supplier_no: data[productIndex].supplier_no || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to convert WebP image URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.imageUrl) {
|
||||||
|
throw new Error("Upload response did not include an image URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
imageUrl = result.imageUrl;
|
||||||
|
fileName = result.fileName || "Converted WebP URL";
|
||||||
|
}
|
||||||
|
|
||||||
// Create a unique ID for this image
|
// Create a unique ID for this image
|
||||||
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
@@ -53,9 +91,9 @@ export const useUrlImageUpload = ({
|
|||||||
const newImage: ProductImageSortable = {
|
const newImage: ProductImageSortable = {
|
||||||
id: imageId,
|
id: imageId,
|
||||||
productIndex,
|
productIndex,
|
||||||
imageUrl: validatedUrl,
|
imageUrl,
|
||||||
loading: false, // We're not loading from server, so it's ready immediately
|
loading: false, // We're not loading from server, so it's ready immediately
|
||||||
fileName: "From URL",
|
fileName,
|
||||||
// Add required schema fields
|
// Add required schema fields
|
||||||
pid: data[productIndex].id || 0,
|
pid: data[productIndex].id || 0,
|
||||||
iid: 0,
|
iid: 0,
|
||||||
@@ -70,7 +108,7 @@ export const useUrlImageUpload = ({
|
|||||||
setProductImages(prev => [...prev, newImage]);
|
setProductImages(prev => [...prev, newImage]);
|
||||||
|
|
||||||
// Update the product data with the new image URL
|
// Update the product data with the new image URL
|
||||||
addImageToProduct(productIndex, validatedUrl);
|
addImageToProduct(productIndex, imageUrl);
|
||||||
|
|
||||||
// Clear the URL input field on success
|
// Clear the URL input field on success
|
||||||
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
|
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
|
||||||
@@ -95,4 +133,4 @@ export const useUrlImageUpload = ({
|
|||||||
handleAddImageFromUrl,
|
handleAddImageFromUrl,
|
||||||
updateUrlInput
|
updateUrlInput
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user