import React, { useEffect, useState, useRef, useCallback } from 'react'; import { observer } from 'mobx-react-lite'; import type { StoreType } from 'polotno/model/store'; import { getCrop } from 'polotno/utils/image'; // redux import { useDispatch } from 'react-redux'; import { AppDispatch } from '../../../../redux/store'; // Utils import { getPublicApiKey, getType } from '../../../../utils/helper'; import { allowedImageTypes } from '../../../../utils/constants'; // Actions import { success } from '../../../../redux/actions/snackbarActions'; // Components import DialogV2 from '../../../../components/GenericUIBlocks/Dialog/V2'; // Icons // @ts-ignore import Upload from '../../../../assets/images/templates/upload-image.svg'; // @ts-ignore import Trash from '../../../../assets/images/templates/trash-upload.svg'; // @ts-ignore import Download from '../../../../assets/images/templates/download.svg'; // @ts-ignore import ConfirmCloseIcon from '../../../../assets/images/modal-icons/confirm-close-icon'; // Styles import './styles.scss'; interface BrandingImage { id: string | number; url: string; type: string; name?: string; meta?: any; } interface CustomUploadsV2Props { store: StoreType; onGetBrandingImages?: (payload: any) => Promise; onDeleteBrandingImage?: (id: string | number) => Promise; onUploadBrandingImage?: (payload: any) => Promise; } const cancelDialogStylesV2 = { maxWidth: '567px', minHeight: 'auto', padding: '40px', }; export const CustomUploadsV2 = observer(({ store, onGetBrandingImages, onDeleteBrandingImage, onUploadBrandingImage, }: CustomUploadsV2Props) => { const [images, setImages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isUploading, setIsUploading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [activeDropdown, setActiveDropdown] = useState(null); const [imageCache, setImageCache] = useState>(new Map()); const [openDeleteImage, setOpenDeleteImage] = useState(false); const [selectedImage, setSelectedImage] = useState(null); const fileInputRef = useRef(null); const dropdownRefs = useRef>(new Map()); const imagesGridRef = useRef(null); const loadingTimeoutRef = useRef(null); const isLoadingRef = useRef(false); const dispatch: AppDispatch = useDispatch(); const loadBrandingImages = useCallback(async (page: number = 1, append: boolean = false, loading = true) => { if (!onGetBrandingImages) return; // Prevent multiple simultaneous calls if (isLoadingRef.current) return; isLoadingRef.current = true; if (page === 1 && loading) { setIsLoading(true); } else { setIsLoadingMore(true); } try { const payload = { page, pageSize: 15 }; const brandingImages = await onGetBrandingImages(payload); if (brandingImages && Array.isArray(brandingImages.rows)) { const normalized = brandingImages.rows.map((img: any) => ({ id: img.id, url: img.fileUrl, type: img.type.toLowerCase(), // Convert to lowercase for consistency name: img.name, meta: img.meta })) as BrandingImage[]; if (append && page > 1) { // Merge with existing images, avoiding duplicates setImages(prevImages => { const existingIds = new Set(prevImages.map(img => img.id)); const newImages = normalized.filter(img => !existingIds.has(img.id)); return [...prevImages, ...newImages]; }); } else { // Replace images for initial load setImages(normalized); } // Update pagination state using API response pagination info const hasMorePages = brandingImages.currentPage < brandingImages.lastPage; setHasMore(hasMorePages); setCurrentPage(brandingImages.currentPage); } else { if (!append) { setImages([]); } setHasMore(false); } } catch (error) { console.error('Failed to load branding images:', error); } finally { setIsLoading(false); setIsLoadingMore(false); isLoadingRef.current = false; } }, [onGetBrandingImages]); // Handle loading more images on scroll const loadMoreImages = useCallback(async () => { if (!hasMore || isLoadingMore || isLoading) return; await loadBrandingImages(currentPage + 1, true); }, [hasMore, isLoadingMore, isLoading, currentPage, loadBrandingImages]); // Debounced scroll handler const handleScroll = useCallback(() => { if (!imagesGridRef.current || !hasMore || isLoadingMore || isLoading) return; const { scrollTop, scrollHeight, clientHeight } = imagesGridRef.current; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; // Load more when scrolled 80% of the way down if (scrollPercentage > 0.75) { // Clear existing timeout if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); } // Debounce the API call by 300ms loadingTimeoutRef.current = setTimeout(() => { loadMoreImages(); }, 300); } }, [hasMore, isLoadingMore, isLoading, loadMoreImages]); const handleOpenDeleteImage = () => { setOpenDeleteImage(true); setActiveDropdown(null); }; const handleCloseDeleteImage = () => { setOpenDeleteImage(false); }; // Handle file validation const validateFile = (file: File): boolean => { if (!file || !allowedImageTypes.includes(file.type)) { console.log('Only image files with extensions JPEG, PNG, or SVG are allowed.'); return false; } if (file.size >= 5 * 1024 * 1024) { // 5MB limit console.log('File size must be under 5MB.'); return false; } return true; }; const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); img.onload = () => { resolve({ width: img.width, height: img.height }); }; img.onerror = reject; img.src = e.target?.result as string; }; reader.onerror = reject; reader.readAsDataURL(file); }); }; // Handle file upload const handleFileUpload = async (files: FileList) => { if (!files.length) return; setIsUploading(true); try { for (const file of Array.from(files)) { if (!validateFile(file)) { continue; } if (onUploadBrandingImage) { const formData = new FormData(); let { width, height } = await getImageDimensions(file); const metaData = { width, height } formData.append('image', file); formData.append('meta_data', JSON.stringify(metaData)); await onUploadBrandingImage(formData); // Reset pagination and reload from first page setCurrentPage(1); setHasMore(true); await loadBrandingImages(1, false, false); } else { const localUrl = URL.createObjectURL(file); const type = getType(file); const tempImage: BrandingImage = { id: `temp-${Date.now()}-${Math.random()}`, url: localUrl, type, name: file.name, }; setImages((prev) => [...prev, tempImage]); // Cache the image URL setImageCache( (prev) => new Map(prev.set(tempImage.url, tempImage.url)) ); } } } catch (error) { console.error('Upload failed:', error); } finally { setIsUploading(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } } }; // Handle delete image const handleDeleteImage = async (imageId: string | number) => { if (!onDeleteBrandingImage) return; try { await onDeleteBrandingImage(imageId); setImages(prev => prev.filter(img => img.id !== imageId)); setActiveDropdown(null); // Clean up cache const imageToDelete = images.find(img => img.id === imageId); if (imageToDelete?.url.startsWith('blob:')) { URL.revokeObjectURL(imageToDelete.url); } setImageCache(prev => { const newCache = new Map(prev); newCache.delete(imageToDelete?.url || ''); return newCache; }); } catch (error) { console.error('Failed to delete image:', error); } }; // Handle download image const handleDownloadImage = async (image: BrandingImage) => { try { const response = await fetch(image.url, { method: 'GET', headers: { Authorization: `Bearer ${getPublicApiKey()}`, }, }); if (!response.ok) throw new Error('Failed to download image'); // Convert response to Blob const blob = await response.blob(); // Create a temporary object URL for download const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = image.name || `branding-image-${image.id}.png`; document.body.appendChild(link); link.click(); // Clean up document.body.removeChild(link); window.URL.revokeObjectURL(url); setActiveDropdown(null); } catch (error) { console.error('Download failed:', error); } }; // Handle image selection for canvas const handleImageSelect = async (image: BrandingImage, pos?: { x: number; y: number }, element?: any) => { const imageUrl = image.url; const type = image.type; const imageMetaData = typeof image?.meta?.data === 'string' ? JSON.parse(image?.meta?.data) : image?.meta?.data || {}; let width = imageMetaData?.width || 100; let height = imageMetaData?.height || 100; if (element && element.type === 'svg' && element.contentEditable && type === 'image') { element.set({ maskSrc: imageUrl }); return; } if (element && element.type === 'image' && element.contentEditable && type === 'image') { const crop = getCrop(element, { width, height }); element.set({ src: imageUrl, ...crop }); return; } const scale = Math.min(store.width / width, store.height / height, 1); width = width * scale; height = height * scale; const x = (pos?.x || store.width / 2) - width / 2; const y = (pos?.y || store.height / 2) - height / 2; store.activePage?.addElement({ type: type === 'svg' ? type : 'image', //@ts-ignore src: imageUrl, x, y, width, height, }); }; // Handle dropdown toggle const toggleDropdown = (imageId: string | number, event: React.MouseEvent) => { event.stopPropagation(); setActiveDropdown(activeDropdown === imageId ? null : imageId); }; // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (activeDropdown) { const dropdownElement = dropdownRefs.current.get(activeDropdown); if (dropdownElement && !dropdownElement.contains(event.target as Node)) { setActiveDropdown(null); } } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [activeDropdown]); // Load images on mount useEffect(() => { loadBrandingImages(1, false); }, []); // Setup scroll event listener useEffect(() => { const gridElement = imagesGridRef.current; if (gridElement) { gridElement.addEventListener('scroll', handleScroll); return () => { gridElement.removeEventListener('scroll', handleScroll); // Clean up timeout on unmount if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); } }; } }, [hasMore, isLoadingMore, isLoading, currentPage, handleScroll]); // Cleanup blob URLs on unmount useEffect(() => { return () => { images.forEach(image => { if (image.url.startsWith('blob:')) { URL.revokeObjectURL(image.url); } }); }; }, []); return (
{/* Upload Section */}
e.target.files && handleFileUpload(e.target.files)} />
{/* Header */}
Accepted File Formats: JPEG, PNG, SVG
Max Size: 5MB
Use high-res images for best print quality.
{/* Images Grid */}
{isLoading ? (
Loading images...
) : images.length === 0 ? (
No images uploaded yet
) : ( <> {images.map((image) => (
handleImageSelect(image)} > {image.name
toggleDropdown(image.id, e)} >
{/* Dropdown Menu */} {activeDropdown === image.id && (
{ if (el) dropdownRefs.current.set(image.id, el); }} className="dropdown-menu" > {onDeleteBrandingImage && }
)}
)) } {/* Loading indicator for pagination */} {isLoadingMore && (
Loading more images...
)} )} } customStyles={cancelDialogStylesV2} open={openDeleteImage} handleClose={handleCloseDeleteImage} title='Delete Image' subHeading='' description='Are you sure you want to delete this image?' onSubmit={() => { handleDeleteImage(selectedImage?.id as string | number); handleCloseDeleteImage(); }} onCancel={handleCloseDeleteImage} cancelText='No' submitText='Yes' isGallery={false} />
); }); export default CustomUploadsV2;