From 36f23b527e0461a106d931713d22c3ad07f8f3f6 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 23 May 2026 15:52:07 -0400 Subject: [PATCH] Image upload consolidation --- .../product-editor/ImageManager.tsx | 183 ++++++++++-------- .../components/GenericDropzone.tsx | 9 +- .../components/ProductCard/ImageDropzone.tsx | 9 +- inventory/src/config/uploads.ts | 7 + 4 files changed, 113 insertions(+), 95 deletions(-) create mode 100644 inventory/src/config/uploads.ts diff --git a/inventory/src/components/product-editor/ImageManager.tsx b/inventory/src/components/product-editor/ImageManager.tsx index 4d0a094..bce9888 100644 --- a/inventory/src/components/product-editor/ImageManager.tsx +++ b/inventory/src/components/product-editor/ImageManager.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useRef } from "react"; import axios from "axios"; import { toast } from "sonner"; +import { useDropzone } from "react-dropzone"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -24,6 +25,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import { IMAGE_UPLOAD_ACCEPT, MAX_UPLOAD_BYTES, formatUploadLimitMb } from "@/config/uploads"; import { DndContext, closestCenter, @@ -222,8 +224,6 @@ export function ImageManager({ const [urlInput, setUrlInput] = useState(""); const [isUploading, setIsUploading] = useState(false); const [addOpen, setAddOpen] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - const fileInputRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { @@ -296,29 +296,51 @@ export function ImageManager({ ); const handleFileUpload = useCallback( - async (files: FileList | null) => { - if (!files || files.length === 0) return; + async (files: File[]) => { + if (files.length === 0) return; setIsUploading(true); setAddOpen(false); try { - for (const file of Array.from(files)) { - const formData = new FormData(); - formData.append("image", file); - const res = await axios.post("/api/import/upload-image", formData); - if (res.data?.imageUrl) { - addNewImage(res.data.imageUrl); + for (const file of files) { + try { + const formData = new FormData(); + formData.append("image", file); + const res = await axios.post("/api/import/upload-image", formData); + if (res.data?.imageUrl) { + addNewImage(res.data.imageUrl); + } + } catch (err) { + const serverMessage = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + toast.error(`Failed to upload ${file.name}${serverMessage ? `: ${serverMessage}` : ""}`); } } - } catch { - toast.error("Failed to upload image"); } finally { setIsUploading(false); - if (fileInputRef.current) fileInputRef.current.value = ""; } }, [addNewImage] ); + const { getRootProps, getInputProps, isDragActive, open: openFilePicker } = useDropzone({ + accept: IMAGE_UPLOAD_ACCEPT, + maxSize: MAX_UPLOAD_BYTES, + multiple: true, + noClick: true, + noKeyboard: true, + disabled: isUploading, + onDrop: handleFileUpload, + onDropRejected: (rejections) => { + rejections.forEach((rejection) => { + const tooLarge = rejection.errors.some((e) => e.code === "file-too-large"); + const reason = tooLarge + ? `larger than ${formatUploadLimitMb()} limit` + : rejection.errors[0]?.message ?? "rejected"; + toast.error(`${rejection.file.name}: ${reason}`); + }); + }, + }); + const handleUrlAdd = useCallback(async () => { const url = urlInput.trim(); if (!url) return; @@ -404,71 +426,76 @@ export function ImageManager({ ))} {/* Add button with file drop */} - - - - - - -
- - setUrlInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleUrlAdd(); - } - }} - className="text-sm h-7" - /> +
+ + + + + + -
- - +
+ + setUrlInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleUrlAdd(); + } + }} + className="text-sm h-7" + /> + +
+ + +
@@ -484,16 +511,6 @@ export function ImageManager({ - {/* Hidden file input */} - handleFileUpload(e.target.files)} - /> - {/* Full-size image overlay */} !open && setZoomImage(null)}> diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx index b19cacd..9effaeb 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/GenericDropzone.tsx @@ -3,8 +3,7 @@ import { Loader2, Upload } from "lucide-react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; - -const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; +import { IMAGE_UPLOAD_ACCEPT, MAX_UPLOAD_BYTES, formatUploadLimitMb } from "@/config/uploads"; interface GenericDropzoneProps { processingBulk: boolean; @@ -22,16 +21,14 @@ export const GenericDropzone = ({ onShowUnassigned }: GenericDropzoneProps) => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { - 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] - }, + accept: IMAGE_UPLOAD_ACCEPT, maxSize: MAX_UPLOAD_BYTES, onDrop, onDropRejected: (rejections) => { rejections.forEach((rejection) => { const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large'); const reason = tooLarge - ? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit` + ? `larger than ${formatUploadLimitMb()} limit` : rejection.errors[0]?.message ?? 'rejected'; toast.error(`${rejection.file.name}: ${reason}`); }); diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx index a142a3e..12f25e2 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx +++ b/inventory/src/components/product-import/steps/ImageUploadStep/components/ProductCard/ImageDropzone.tsx @@ -2,8 +2,7 @@ import { Upload } from "lucide-react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; - -const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; +import { IMAGE_UPLOAD_ACCEPT, MAX_UPLOAD_BYTES, formatUploadLimitMb } from "@/config/uploads"; interface ImageDropzoneProps { productIndex: number; @@ -12,9 +11,7 @@ interface ImageDropzoneProps { export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => { const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { - 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.tif', '.tiff'] - }, + accept: IMAGE_UPLOAD_ACCEPT, maxSize: MAX_UPLOAD_BYTES, onDrop: (acceptedFiles) => { onDrop(acceptedFiles); @@ -23,7 +20,7 @@ export const ImageDropzone = ({ onDrop }: ImageDropzoneProps) => { rejections.forEach((rejection) => { const tooLarge = rejection.errors.some((e) => e.code === 'file-too-large'); const reason = tooLarge - ? `larger than ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit` + ? `larger than ${formatUploadLimitMb()} limit` : rejection.errors[0]?.message ?? 'rejected'; toast.error(`${rejection.file.name}: ${reason}`); }); diff --git a/inventory/src/config/uploads.ts b/inventory/src/config/uploads.ts new file mode 100644 index 0000000..6dedeb3 --- /dev/null +++ b/inventory/src/config/uploads.ts @@ -0,0 +1,7 @@ +export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; + +export const formatUploadLimitMb = () => `${MAX_UPLOAD_BYTES / 1024 / 1024}MB`; + +export const IMAGE_UPLOAD_ACCEPT: Record = { + "image/*": [".jpeg", ".jpg", ".png", ".gif", ".webp", ".tif", ".tiff"], +};